diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b047effb..af21e05b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: run: cargo fmt --all -- --check - name: clippy run: cargo clippy --workspace --all-targets -- -D warnings + # The facade's optional `python-host` feature is off in the workspace + # run, so lint it on its own. + - name: clippy (cpex, all features) + run: cargo clippy -p cpex --all-targets --all-features -- -D warnings unused-deps: name: Unused deps (advisory) @@ -81,6 +85,10 @@ jobs: # e2e tests are also `#[ignore]`d here — the `python-e2e` job below runs # them with a real interpreter and framework checkout. run: cargo test --workspace + - name: cargo test (cpex facade, python-host) + # Compiles the facade's python-host tests. They stop at plugin + # instantiation and never spawn an interpreter. + run: cargo test -p cpex --features python-host # The Python host's end-to-end tests, actually run. # @@ -98,16 +106,10 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 1 - # The Python framework lives on its own branch, and PyPI's `cpex` is behind - # it (its worker.py predates the credential and extensions fields). The - # *stable* 0.1.x branch is not enough either: it carries no - # cpex/framework/isolated/worker.py at all. The companion branch below is - # the one whose worker.py both consumes `credential` and delivers - # `extensions=` to execute_plugin, which is what these tests gate on. - # - # This is a cross-branch dependency: when the Python side merges, retarget - # this ref. Until then a wrong ref fails loudly (skips are errors here) - # rather than silently skipping — which is the whole point of this job. + # The 0.1.x branch carries the isolated worker with credential and + # extensions support. PyPI's `cpex` may lag that worker, so these tests + # install directly from the checked-out branch. A missing worker fails + # this job rather than silently skipping the integration tests. - name: Checkout the cpex Python framework uses: actions/checkout@v7 with: @@ -128,6 +130,117 @@ jobs: CPEX_PYTHON_SOURCE: ${{ github.workspace }}/cpex-python run: make test-python-e2e + # The PyO3 bindings: in-tree pytest suite (built with `maturin develop`), + # then a release wheel installed into a fresh venv and exercised from + # outside the source tree. The second step is what catches a wheel that + # builds but is missing its native module, package or stubs. + python-bindings: + name: Python bindings (pytest + wheel smoke) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - name: Install Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" + - uses: Swatinem/rust-cache@v2 + - name: make bindings-python-test + run: make bindings-python-test + - name: make bindings-python-smoke + run: make bindings-python-smoke + + # The Go package against the cpex-ffi release library, on both platforms + # the FFI ships for. GO_STATIC=1 removes the shared library so Go links + # libcpex_ffi.a the way release consumers do, which also checks the + # native link flags in go/cpex/ffi.go (macOS has needed extra frameworks + # before). The race run covers the shared tokio runtime behind the FFI. + go: + name: Go (${{ matrix.os }}) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + # Linux only: macOS runners have no Docker, so service containers are not + # available there. A blank image skips the service on that leg, and the + # Valkey acceptance test skips when CPEX_TEST_VALKEY_URL is empty. + services: + valkey: + image: ${{ matrix.os == 'ubuntu-latest' && 'valkey/valkey:8-alpine' || '' }} + ports: + - 6379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + CPEX_TEST_VALKEY_URL: ${{ matrix.os == 'ubuntu-latest' && 'redis://127.0.0.1:6379' || '' }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" + - name: Install Go + uses: actions/setup-go@v7 + with: + go-version-file: go/cpex/go.mod + cache-dependency-path: go/cpex/go.sum + - uses: Swatinem/rust-cache@v2 + # One make invocation, so the release library is built (and its shared + # copy removed) once for all three targets. + - name: make go-vet go-test go-test-race + run: make go-vet go-test go-test-race GO_STATIC=1 + + # Replays the 0.2.3 decision snapshots against the current engine, with a + # Valkey so the session-store scenarios and the stored-label read-back run + # too. `make parity` fails when PARITY_VALKEY_URL is set and anything is + # still skipped. The workspace `test` job runs the same suite without + # Valkey. + parity: + name: Parity (0.2.3 snapshots, with Valkey) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + valkey: + image: valkey/valkey:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" + - uses: Swatinem/rust-cache@v2 + - name: make parity + env: + # The harness runs FLUSHDB on this database; the service is + # throwaway. + PARITY_VALKEY_URL: redis://127.0.0.1:6379/0 + run: make parity + examples: name: Examples build (Rust + Go FFI) if: github.event_name != 'pull_request' || !github.event.pull_request.draft @@ -144,10 +257,12 @@ jobs: - name: Install Go uses: actions/setup-go@v7 with: - go-version: "1.25.4" + go-version-file: examples/go-demo/go.mod + cache-dependency-path: examples/go-demo/go.sum - uses: Swatinem/rust-cache@v2 - # Builds every Rust example plus the Go demo (which links the release - # cpex-ffi cdylib) — the cheapest guard against stale public-API usage. + # Builds every Rust example (the tutorial modules) plus the Go demo + # (which links the release cpex-demo-ffi library), the cheapest guard + # against stale public-API usage. - name: make examples-build run: make examples-build @@ -178,15 +293,17 @@ jobs: # every publishable member without uploading. Catches publishability # regressions (missing version reqs, packaging-excluded files, broken # inter-crate deps) on every push/PR, long before a release tag. Mirrors - # `make publish-dry` and the release workflow's dry-run. The - # `publish = false` crates are excluded (not part of the registry set): - # the two FFI crates and the tutorial companion crate. + # `make publish-dry` and the release workflow's dry-run. That covers + # cpex, cpex-hosts-python, cpex-plugin-delegator-biscuit and + # cpex-python. The `publish = false` crates are excluded (not part of + # the registry set): the two FFI crates, the tutorial companion crate, + # and the parity harness. - name: cargo package (no upload) run: | # Drop any pre-seeded registry index/cache so cargo fetches fresh and # --locked resolves against a clean index (see note above). rm -rf ~/.cargo/registry/index ~/.cargo/registry/cache - cargo package --workspace --locked --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial + cargo package --workspace --locked --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial --exclude cpex-parity-harness docs: name: Docs Build diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml index 296ef619..10e53dec 100644 --- a/.github/workflows/release-ffi.yaml +++ b/.github/workflows/release-ffi.yaml @@ -83,9 +83,9 @@ jobs: key: ${{ matrix.target }} - name: Install musl toolchain - # ring (via jsonwebtoken/rustls/quinn) compiles C + asm through - # cc-rs, which for a *-unknown-linux-musl target shells out to - # -linux-musl-gcc. The runners ship only the glibc gcc, so + # ring (via rustls) and aws-lc-sys (via jsonwebtoken in the PPE + # builtins) compile C + asm through cc-rs, which for a + # *-unknown-linux-musl target shells out to -linux-musl-gcc. The runners ship only the glibc gcc, so # we install musl-gcc here. The matrix runs each musl target on # its native-arch runner, so musl-gcc targets the host arch and # the CC_/LINKER env vars below redirect cc-rs and the linker to diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7da30145..b9fb15e5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,10 +4,12 @@ # # Triggered by a semver tag (vX.Y.Z). Validates the tag matches the # workspace version, runs the test suite, then publishes every -# publishable crate to crates.io in dependency (leaf-first) order. +# publishable crate to crates.io in dependency (leaf-first) order: +# cpex-hosts-python, cpex-plugin-delegator-biscuit, cpex, cpex-python. # # `cpex-ffi` is `publish = false` (distributed as signed prebuilt -# artifacts by release-ffi.yaml), so it is intentionally absent below. +# artifacts by release-ffi.yaml), so it is intentionally absent below, +# as are the example, tutorial and parity harness crates. # # Requires repo secret: CARGO_REGISTRY_TOKEN. @@ -89,43 +91,46 @@ jobs: DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} run: | set -euo pipefail + # Dependency order. Only these four members publish; the engine + # itself comes from the praxis-policy crates on crates.io, which + # are already there. `cpex` takes `cpex-hosts-python` as an + # optional dependency, and cargo still needs it in the index, so + # the host goes first. `cpex-plugin-delegator-biscuit` has no member + # dependency; `cpex-python` depends on `cpex`, so it goes last. + # Each `cargo publish` + # blocks until the crate is visible in the index before returning, + # so the next crate's dependency resolves; the short sleep is + # extra slack for propagation. + crates=( + cpex-hosts-python + cpex-plugin-delegator-biscuit + cpex + cpex-python + ) + # The list must name exactly the publishable members: a missing one + # stops the release halfway, as it did for 0.2.2. Compare against + # cargo metadata first, in dry runs too. `publish = false` shows up + # there as an empty list. + expected="$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.publish != []) | .name' | sort)" + listed="$(printf '%s\n' "${crates[@]}" | sort)" + if [ "$expected" != "$listed" ]; then + echo "::error::publish list does not match the publishable workspace members" + diff <(echo "$expected") <(echo "$listed") || true + exit 1 + fi # Dry run: build + verify a .crate for every publishable member - # (cargo skips `publish = false` crates and resolves inter-member - # deps against each other's packaged versions) without uploading. - # This is how the workflow is exercised without a real release. + # (cargo resolves inter-member deps against each other's packaged + # versions) without uploading. This is how the workflow is + # exercised without a real release. if [ "$DRY_RUN" = "true" ]; then echo "::group::dry run — cargo package (no upload)" - # Verify the crates.io-published set only; these three + # Verify the crates.io-published set only; these four # publish=false crates are not part of the registry release. - cargo package --workspace --locked --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial + cargo package --workspace --locked --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial --exclude cpex-parity-harness echo "::endgroup::" exit 0 fi - # Leaf-first topological order. Each `cargo publish` blocks until the - # crate is visible in the index before returning, so the next crate's - # dependency resolves; the short sleep is extra slack for propagation. - crates=( - cpex-orchestration - cpex-core - cpex-sdk - apl-core - apl-cmf - apl-cpex - cpex-plugin-pii-scanner - cpex-plugin-audit-logger - cpex-plugin-identity-jwt - cpex-plugin-delegator-oauth - cpex-plugin-delegator-biscuit - cpex-plugin-elicitation-ciba - cpex-pdp-cedar-direct - cpex-pdp-cel - cpex-pdp-opa - cpex-session-valkey - cpex-hosts-python - cpex-builtins - cpex - cpex-python - ) for c in "${crates[@]}"; do echo "::group::publish $c" # Idempotent: a prior run can publish a leading prefix of this diff --git a/.github/workflows/tutorial.yaml b/.github/workflows/tutorial.yaml index cd7127d0..dafd4d04 100644 --- a/.github/workflows/tutorial.yaml +++ b/.github/workflows/tutorial.yaml @@ -8,6 +8,9 @@ # `--check`, and tear it down. name: Tutorial +# The modules run against the facade, the kept builtin and the PPE crates +# pinned in Cargo.toml/Cargo.lock, so a change to any of those triggers the +# run on pull requests as well as on main. on: push: branches: [main] @@ -15,10 +18,17 @@ on: - "examples/tutorial/**" - "crates/**" - "builtins/**" + - "Cargo.toml" + - "Cargo.lock" - ".github/workflows/tutorial.yaml" pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - "examples/tutorial/**" + - "crates/**" + - "builtins/**" + - "Cargo.toml" + - "Cargo.lock" - ".github/workflows/tutorial.yaml" jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 827161af..c2d369fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,68 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [0.3.0] - 2026-10-08 + +CPEX now runs on the [praxis-policy engine](https://github.com/praxis-proxy/policy) (PPE) 0.5.0 from crates.io instead of its own engine crates. API names, hook names, several config keys, defaults and security checks change. The [migration guide](docs/content/docs/migration-0.3.md) lists every change by surface. + +PPE 0.5.0 also updates CEL to 0.15, adds the `delegator/vault` builtin, validates OAuth token responses more strictly, preserves secret-header provenance, and retains secret last-success timestamps after failed refreshes. Changes to resolved `secrets:` declarations require a restart. The migration guide documents these changes and their parity effects. + +### Added + +- **OPA, CIBA, api-key identity and the Vault secret provider in the Python wheel and Go FFI library.** Bindings now ship every non-experimental PPE builtin. +- **Secret refresh from every surface.** Python `refresh_secrets()` and `secrets_last_success()`, Go `RefreshSecrets()` and `SecretsLastSuccess()`, and C `cpex_refresh_secrets` and `cpex_secrets_last_success`. Vault values only renew when the host calls refresh, so binding hosts should call it on an interval and alarm on the last-success time. +- **Private-destination opt-in for outbound HTTP.** Bindings install PPE's HTTP transport, which refuses loopback and private addresses by default. Python `PolicyEngine(..., allow_private_destinations=True)`, Go `EnableAPL(WithPrivateDestinations())` and the C `cpex_apl_install` flag bit allow them. The switch also opens link-local addresses, including cloud metadata, for every outbound builtin, so keep it to development and trusted networks. +- **Typed `identity.resolve` from Python.** The binding sends an `IdentityPayload`, so JWT identity works from Python. +- **Legacy payload translation in the Python plugin host.** Plugins written for the 0.1.x Python worker keep working on PPE's `cmf.*` tool, prompt and resource hooks. The host translates payloads for workers that do not report the new `cmf_hooks` feature, and refuses any message it cannot represent with `legacy_shim_unsupported`. LLM hooks have no legacy form, so a plugin declaring one requires a worker with `cmf_hooks` at startup. +- **Parity and acceptance suites.** `tests/parity` replays 68 recorded 0.2.3 decisions against 0.3.0 and fails on any difference not listed with its cause. Python and Go acceptance suites drive identity, delegation, Cedar, OPA and session labels through the native libraries. CI also builds and smoke-tests the Python wheel and runs Go race tests on Linux and macOS. +- **Docs pages adapted from PPE:** HTTP routing, assertions, auditing, identity claim mapping, CMF extensions and a crate map. +- **`cpex::host` module.** The defaults every binding host shares: `install_host_defaults`, `secret_error_kind`, `REMOVED_HOOKS`, `removed_hook_replacement` and `resolved_identity_fields`. A Rust host that embeds the engine the way the bindings do can call them instead of copying the logic. +- **PPE identity and delegation payloads in the Python plugin host.** A Python plugin on `identity.resolve` or `token.delegate` now accepts PPE's `IdentityPayload` and `DelegationPayload`, which PPE's FFI, its Python bindings and the APL `delegate(...)` step build. The plugin's `IdentityResult` or `DelegationResult` maps back onto the dispatched payload. A field PPE's types cannot hold fails closed. +- **Go extension slots.** `SecurityExtension.Client`, `CallerWorkload` and `ThisWorkload`, `Extensions.CandidateConstraint`, the HTTP request line (`Method`, `Path`, `Host`, `Scheme`) and response `Status` on `HttpExtension`, and the MCP and delegation fields Go was missing. + +### Changed + +- **BREAKING: `PluginManager` is `PolicyEngine` on every surface.** Rust `cpex::PolicyEngine`, Go `NewPolicyEngine` / `NewPolicyEngineDefault` / `PolicyEngine`. Python keeps `PluginManager` as an alias of `PolicyEngine`. +- **BREAKING: C ABI version 2 -> 3.** `cpex_manager_new*` is `cpex_engine_new*`, `CpexManagerInner` is `CpexEngineInner` (built with `CpexEngineInner::new`), and `cpex_apl_install` takes a `flags` argument. Go and the FFI artifact must be upgraded together. +- **BREAKING: Rust paths follow PPE.** `cpex_sdk` items come from `cpex::prelude`, engine modules from `cpex::praxis_policy_core`, and `PipelineResult` from `cpex::praxis_policy_core::executor`. +- **BREAKING: facade features removed.** `pii`, `audit`, `jwt`, `oauth`, `elicitation-ciba`, `cedar`, `cel`, `valkey`, `builtins` and `full` are gone. All stable builtins are always on; `python-host` is the only feature. +- **BREAKING: hook names follow PPE.** The unprefixed names (`tool_pre_invoke`, `identity_resolve`, `token_delegate`, `prompt_pre_fetch` and the rest) are rejected at load. Use `cmf.*`, `cmf.prompt_pre_invoke`, `identity.resolve` and `token.delegate`. +- **BREAKING (Go): `SubjectExtension.Claims` is `map[string]any`.** Claims keep their JSON shape, so read one with a type switch or assertion. A token with a non-string claim used to fail to decode. +- **BREAKING (Go): `SecurityExtension.Agent` and `AgentIdentity` are removed.** Rust has no such field, so the values were dropped on the way in. Use `ThisWorkload` (`WorkloadIdentity`, with `SpiffeID`, `TrustDomain` and `ClientID`). +- **BREAKING (Python plugin host): a `token.delegate` plugin on PPE's `DelegationPayload` needs `read_inbound_credentials` to receive the bearer token.** It used to be gated on `read_delegated_tokens`. The bearer token is the caller's inbound credential, and PPE's own OAuth delegator declares `read_inbound_credentials`. A plugin that declared only `read_delegated_tokens` now gets an error instead of the token. +- **Python `PolicyEngine` lifecycle.** `shutdown()` starts when called, immediately refuses new work, and waits for an `initialize()` in flight. Awaiting reports completion; dropping or cancelling the awaitable does not stop teardown. A timed-out `shutdown()` can be retried. An `initialize()` that times out rolls back the plugins it started once it ends, and leaves the engine terminal. A panicking secret provider makes `refresh_secrets()` raise `RuntimeError`. +- **Legacy payload shim: siblings on pre hooks pass through.** On `cmf.tool_pre_invoke`, `cmf.prompt_pre_invoke` and `cmf.resource_pre_fetch`, content parts beside the one the legacy payload carries are returned unchanged. The plugin authorizes the part it sees. +- **BREAKING: the policy document follows PPE 0.5.0.** The `apl:` wrapper and `global.apl:` are gone, phase lists nest under `authorization:`, empty authorization blocks fail, `plugin_settings` is `engine_settings`, `global.policies` is replaced by top-level `groups:`, route `plugins:` lists are removed, `priority:` is valid only under hook dispatch, and `plugin(x)` steps are `run(x)`. +- **BREAKING: dispatch defaults to `policy`.** Identity runs only on routes that list `authentication:`, requests select routes by `meta`, and a request without it is denied with `unidentified_request` when the config declares a policy. A declared plugin no policy reaches fails the load. +- **BREAKING: outbound plugins must declare `perform_http`.** JWT with `jwks_url`, OAuth delegation, CIBA and Vault fail to initialize without it. A blocked private JWKS shows as `auth.jwks_unavailable`. +- **A plugin panic is a denial.** PPE contains it and reports violation code `plugin_panic`; the FFI returns `RC_OK` with that violation instead of `RC_PANIC`. +- **Rule-path deny codes drop `.apl.`.** `routes.tool:x.apl.pre_invocation[0]` is `routes.tool:x.pre_invocation[0]`. +- **Rust edition 2024 and resolver 3.** The toolchain stays pinned to 1.96.0. + +### Removed + +- **The engine crates.** `cpex-core`, `cpex-orchestration`, `cpex-sdk`, `cpex-builtins`, `apl-core`, `apl-cmf`, `apl-cpex`, and the `cpex-plugin-*`, `cpex-pdp-*` and `cpex-session-valkey` crates are no longer built. Their 0.2.3 releases stay on crates.io. +- **`pii-scanner` and `audit-logger`.** They are now unpublished PPE reference plugins. Rust hosts can copy their source into their own workspace and register them; the bindings do not include them. A CEL or APL rule, the APL `str | redact` pipeline, or a host plugin replaces most uses. + +### Fixed + +- **`cpex_invoke_resolved` passes the caller's extensions to identity resolution.** Without them every Go `InvokeResolved` call was refused under policy dispatch. +- **Go `InvokeResolved` no longer double-frees a context table after an error.** +- **Go `Resource.Blob` and messages without content parts decode across the FFI.** Both used to fail. `Resource.Blob` is now `ByteArray`, which matches the wire format Rust uses for bytes; a `[]byte` value still assigns to it. +- **`EnableAPL` and `cpex_apl_install` stay failed after a panic.** A panicked call used to leave a half-installed engine that a second call reported as installed. Now that call and every later one return `ErrCpexPanic` / `RC_PANIC`; discard the engine. +- **Python plugin host worker lifecycle.** A respawn under way when the plugin shuts down no longer starts a worker nothing stops. A respawn whose replacement fails verification no longer disables the plugin for good: the next call retries within the same budget. A capabilities probe that failed at startup is retried once, the first time a `cmf.*` hook needs it, instead of pinning the worker to the legacy shim. + +### Security + +- **OAuth token-exchange denials no longer forward the IdP's `error_description`.** From PPE 0.2.0. +- **Removed hook names are refused at invoke time.** A Go `InvokeByName` or `InvokeResolved`, Python `invoke_hook` or C `cpex_invoke` / `cpex_invoke_resolved` call naming one reached no handler and came back allowed with no policy evaluated. Go now returns `ErrCpexInvalidInput`, Python raises `ValueError` naming the replacement, and the FFI returns `RC_INVALID_INPUT`. +- **`identity.resolve` input cannot carry a resolved identity.** An input may set only `source`, `source_header`, `headers`, `client_host` and `client_port`. One that sets `subject`, `client`, `caller_workload`, `delegation`, `raw_credentials`, `resolved_at` or `raw_claims` could forge the identity resolvers return, and is refused on every binding (`ValueError`, `ErrCpexInvalidInput`, `RC_INVALID_INPUT`). +- **The `identity.resolve` result no longer echoes the request `headers`.** They include the credential header, so echoing them handed the inbound token back to anything that logs or forwards the result. Applies to Python `modified_payload` and to the Go and C identity payload. Every other field comes back as before. +- **Go: an empty `DataPolicy.AllowedActions` no longer allows every action.** An empty slice was dropped on the way to Rust and read as unconstrained. Now nil allows every action and an empty non-nil slice allows none. +- **Python plugin host withholds raw headers by default.** PPE's public `IdentityPayload` and the outbound `http` extension send empty raw header maps to a worker, because an unknown header name or an unvalidated value can hold a credential. Request-line fields still cross. Only a plugin declaring `read_inbound_credentials` receives the full identity header map, through the gated `credential` object. Worker-returned header edits still require `write_headers` and have known credential names removed. +- **Legacy payload shim refuses post-hook messages with unseen content.** On `cmf.tool_post_invoke`, `cmf.prompt_post_invoke` and `cmf.resource_post_fetch`, a message holding a content part beside the result the legacy payload carries fails closed with `legacy_shim_unsupported`. The plugin cannot see that part, so a filter would pass it through unredacted. +- **CIBA approvals are bound to requester, route and tool and redeemed once.** PPE 0.5.0 fixes the cross-subject approval reuse present in 0.2.3. Dispatch requires a non-empty authenticated subject id. + ## [0.2.3] - 2026-10-02 ### Added @@ -62,7 +124,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - **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. +- **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". Outbound `http` header maps are empty by default; worker-returned edits are filtered for known credential names and guarded by `write_headers`. The FFI, Python-bindings, and audit paths are untouched, and the in-process hosts are unaffected. **Operators should note the residual exposure the capability gate does not close.** Once the plaintext is resident in the worker process it is readable by every transitively-installed dependency in that plugin's venv — a materially larger and less audited trust boundary than the in-process host, which neither the gate nor the transport can constrain. Grant these two capabilities only to plugins whose venv contents you control, and treat a plugin's requirements file as credential-adjacent supply chain. (#149) @@ -146,7 +208,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Initial release -[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.2.3...HEAD +[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.3.0...HEAD +[0.3.0]: https://github.com/contextforge-org/cpex/compare/0.2.3...0.3.0 [0.2.3]: https://github.com/contextforge-org/cpex/compare/0.2.2...0.2.3 [0.2.2]: https://github.com/contextforge-org/cpex/compare/0.2.1...0.2.2 [0.2.1]: https://github.com/contextforge-org/cpex/compare/0.2.0...0.2.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73f24404..9a771dbc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,8 +39,8 @@ Before submitting a PR, make sure `make ci` passes. ## Coding standards -- **Edition 2021**, MSRV **1.96**. Keep the toolchain, `clippy.toml` `msrv`, and - `rust-version` in sync when bumping. +- **Edition 2024**, MSRV **1.96**. Keep the toolchain, `clippy.toml` `msrv`, + `rust-version` and the workspace `edition` in sync when bumping. - **Formatting:** `cargo fmt` (config in [`rustfmt.toml`](rustfmt.toml)). CI runs `cargo fmt --all -- --check`. - **Lints:** the workspace lint policy lives in `[workspace.lints]` in the root @@ -71,7 +71,7 @@ Each source file should carry an Apache-2.0 SPDX header. For Rust: The docs site is Hugo (in [`docs/`](docs)). A few conventions keep it consistent: -- **Route syntax:** write the flat list form the runtime parses, `routes:` with `- tool: ` entries. The map-keyed form is `apl-core`'s standalone/test surface only; see [Configuration](https://contextforge-org.github.io/cpex/docs/configuration/). +- **Route syntax:** write the flat list form the runtime parses, `routes:` with `- tool: ` entries. The map-keyed form is `praxis-policy-apl-core`'s standalone/test surface only; see [Configuration](https://contextforge-org.github.io/cpex/docs/configuration/). - **Terminology:** "Quick Start" (two words) in prose; `builtin` (one word) for the bundled extensions; "allow/deny" for outcomes. Expand `APL` (Authorization Policy Language), `A2A` (agent-to-agent), `IdP` (identity provider), `PDP`, `CMF`, and `CIBA` on first use in a page. - **Images:** reference them relative (`images/foo.png`), not with a hardcoded base path (`/cpex/images/...`). The site render hook resolves them against `baseURL`, so previews and alternate deployments work. Put the file under `docs/static/images/`. - **Runnable examples:** prefer linking the tutorial crate (`examples/tutorial`) over maintaining a second partial example; its modules and tests are CI-run. diff --git a/Cargo.lock b/Cargo.lock index 7e92c575..68da8b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,12 +28,11 @@ dependencies = [ [[package]] name = "antlr4rust" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +checksum = "003352ee03bab0eea34057cc541fa4362734220ca381343c7e9c35050c1fc942" dependencies = [ - "better_any", - "bit-set", + "bit-set 0.11.1", "byteorder", "lazy_static", "murmur3", @@ -49,50 +48,6 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" -[[package]] -name = "apl-cmf" -version = "0.2.3" -dependencies = [ - "apl-core", - "async-trait", - "cpex-core", - "serde_json", - "tokio", -] - -[[package]] -name = "apl-core" -version = "0.2.3" -dependencies = [ - "async-trait", - "cpex-orchestration", - "futures", - "regex", - "serde", - "serde_json", - "serde_yaml", - "thiserror 2.0.20", - "tokio", -] - -[[package]] -name = "apl-cpex" -version = "0.2.3" -dependencies = [ - "apl-cmf", - "apl-core", - "async-trait", - "chrono", - "cpex-core", - "serde", - "serde_json", - "serde_yaml", - "sha2 0.11.0", - "thiserror 2.0.20", - "tokio", - "tracing", -] - [[package]] name = "ar_archive_writer" version = "0.5.2" @@ -138,32 +93,6 @@ dependencies = [ "term", ] -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "astral-tokio-tar" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" -dependencies = [ - "filetime", - "futures-core", - "libc", - "portable-atomic", - "rustc-hash", - "tokio", - "tokio-stream", - "xattr", -] - [[package]] name = "async-lock" version = "3.4.2" @@ -175,28 +104,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "async-trait" version = "0.1.92" @@ -243,49 +150,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "backon" version = "1.6.0" @@ -314,16 +178,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "base64ct" -version = "1.8.3" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] -name = "better_any" -version = "0.2.1" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "biscuit-auth" @@ -342,8 +206,8 @@ dependencies = [ "nom", "p256", "pkcs8 0.9.0", - "prost 0.10.4", - "prost-types 0.10.1", + "prost", + "prost-types", "rand 0.8.6", "rand_core 0.6.4", "regex", @@ -387,7 +251,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", ] [[package]] @@ -396,6 +269,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "2.13.0" @@ -430,90 +313,27 @@ dependencies = [ ] [[package]] -name = "bollard" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" -dependencies = [ - "async-stream", - "base64 0.22.1", - "bitflags", - "bollard-buildkit-proto", - "bollard-stubs", - "bytes", - "chrono", - "futures-core", - "futures-util", - "hex", - "home", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-rustls", - "hyper-util", - "hyperlocal", - "log", - "num", - "pin-project-lite", - "rand 0.9.4", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_derive", - "serde_json", - "serde_repr", - "serde_urlencoded", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-buildkit-proto" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" -dependencies = [ - "prost 0.14.4", - "prost-types 0.14.4", - "tonic", - "tonic-prost", - "ureq", -] - -[[package]] -name = "bollard-stubs" -version = "1.49.1-rc.28.4.0" +name = "borsh" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ - "base64 0.22.1", - "bollard-buildkit-proto", + "borsh-derive", "bytes", - "chrono", - "prost 0.14.4", - "serde", - "serde_json", - "serde_repr", - "serde_with", + "cfg_aliases", ] [[package]] -name = "borsh" +name = "borsh-derive" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ - "bytes", - "cfg_aliases", + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -631,18 +451,19 @@ dependencies = [ [[package]] name = "cel" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814" +checksum = "044e21070125024b84c8058036de182400bd99212cab3faa4f19566f7733b6dc" dependencies = [ "antlr4rust", "chrono", + "chrono-tz", "lazy_static", "nom", "pastey", "regex", "serde", - "thiserror 1.0.69", + "thiserror 2.0.20", ] [[package]] @@ -707,6 +528,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -716,20 +543,11 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "futures-core", @@ -760,6 +578,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "core-foundation" version = "0.10.1" @@ -778,54 +602,15 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex" -version = "0.2.3" +version = "0.3.0" dependencies = [ - "apl-cmf", - "apl-core", - "apl-cpex", - "cpex-builtins", - "cpex-core", "cpex-hosts-python", -] - -[[package]] -name = "cpex-builtins" -version = "0.2.3" -dependencies = [ - "apl-core", - "apl-cpex", - "cpex-core", - "cpex-pdp-cedar-direct", - "cpex-pdp-cel", - "cpex-pdp-opa", - "cpex-plugin-audit-logger", - "cpex-plugin-delegator-oauth", - "cpex-plugin-elicitation-ciba", - "cpex-plugin-identity-jwt", - "cpex-plugin-pii-scanner", - "cpex-session-valkey", - "tokio", -] - -[[package]] -name = "cpex-core" -version = "0.2.3" -dependencies = [ - "arc-swap", - "async-trait", - "chrono", - "cpex-orchestration", - "hashbrown 0.17.1", + "praxis-policy", "serde", "serde_json", - "serde_yaml", - "thiserror 2.0.20", "tokio", - "tokio-util", "tracing", - "uuid", - "wildmatch", - "zeroize", + "tracing-subscriber", ] [[package]] @@ -833,19 +618,20 @@ name = "cpex-demo-ffi" version = "0.1.0" dependencies = [ "async-trait", - "cpex-core", "cpex-ffi", + "praxis-policy-core", "tracing", ] [[package]] name = "cpex-ffi" -version = "0.2.3" +version = "0.3.0" dependencies = [ - "apl-cpex", "async-trait", - "cpex-builtins", - "cpex-core", + "aws-lc-rs", + "cpex", + "jsonwebtoken", + "praxis-policy-core", "rmp-serde", "serde", "serde_bytes", @@ -856,166 +642,51 @@ dependencies = [ [[package]] name = "cpex-hosts-python" -version = "0.2.3" +version = "0.3.0" dependencies = [ "async-trait", "chrono", - "cpex-core", "cpex-hosts-python", + "praxis-policy", + "praxis-policy-core", "serde", "serde_json", - "serde_yaml", "sha2 0.11.0", "tokio", "tracing", "uuid", + "yaml_serde", ] [[package]] -name = "cpex-orchestration" -version = "0.2.3" -dependencies = [ - "futures", - "tokio", -] - -[[package]] -name = "cpex-pdp-cedar-direct" -version = "0.2.3" -dependencies = [ - "apl-cmf", - "apl-core", - "apl-cpex", - "async-trait", - "cedar-policy", - "cpex-core", - "futures", - "serde_json", - "serde_yaml", - "stacker", - "thiserror 2.0.20", - "tokio", -] - -[[package]] -name = "cpex-pdp-cel" -version = "0.2.3" -dependencies = [ - "apl-cmf", - "apl-core", - "apl-cpex", - "async-trait", - "cel", - "cpex-core", - "serde_yaml", - "thiserror 2.0.20", - "tokio", - "tracing", -] - -[[package]] -name = "cpex-pdp-opa" -version = "0.2.3" -dependencies = [ - "apl-cmf", - "apl-core", - "apl-cpex", - "async-trait", - "cpex-core", - "regorus", - "serde_json", - "serde_yaml", - "thiserror 2.0.20", - "tokio", - "tracing", -] - -[[package]] -name = "cpex-plugin-audit-logger" -version = "0.2.3" +name = "cpex-parity-harness" +version = "0.0.0" dependencies = [ "async-trait", - "chrono", - "cpex-core", + "aws-lc-rs", + "base64 0.22.1", + "cpex", + "form_urlencoded", + "praxis-policy-core", + "redis", "serde", "serde_json", + "sha2 0.10.9", + "tiny_http", "tokio", "tracing", + "yaml_serde", ] [[package]] name = "cpex-plugin-delegator-biscuit" -version = "0.2.3" +version = "0.3.0" dependencies = [ "async-trait", "biscuit-auth", "chrono", - "cpex-core", "hex", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "cpex-plugin-delegator-oauth" -version = "0.2.3" -dependencies = [ - "async-trait", - "base64 0.22.1", - "chrono", - "cpex-core", - "mockito", - "reqwest", - "serde", - "serde_json", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "cpex-plugin-elicitation-ciba" -version = "0.2.3" -dependencies = [ - "async-trait", - "base64 0.22.1", - "chrono", - "cpex-core", - "mockito", - "reqwest", - "serde", - "serde_json", - "tokio", - "zeroize", -] - -[[package]] -name = "cpex-plugin-identity-jwt" -version = "0.2.3" -dependencies = [ - "async-trait", - "base64 0.22.1", - "chrono", - "cpex-core", - "futures", - "jsonwebtoken", - "mockito", - "reqwest", - "rsa", - "serde", - "serde_json", - "tokio", - "tracing", -] - -[[package]] -name = "cpex-plugin-pii-scanner" -version = "0.2.3" -dependencies = [ - "async-trait", - "cpex-core", - "regex", + "praxis-policy-core", "serde", "serde_json", "tokio", @@ -1023,11 +694,11 @@ dependencies = [ [[package]] name = "cpex-python" -version = "0.2.3" +version = "0.3.0" dependencies = [ "async-trait", - "cpex-builtins", - "cpex-core", + "cpex", + "praxis-policy-core", "pyo3", "pyo3-async-runtimes", "serde", @@ -1036,40 +707,13 @@ dependencies = [ "tracing", ] -[[package]] -name = "cpex-sdk" -version = "0.2.3" -dependencies = [ - "cpex-core", -] - -[[package]] -name = "cpex-session-valkey" -version = "0.2.3" -dependencies = [ - "apl-cpex", - "async-trait", - "deadpool-redis", - "redis", - "serde", - "serde_yaml", - "sha2 0.11.0", - "testcontainers", - "testcontainers-modules", - "thiserror 2.0.20", - "tokio", - "tracing", - "url", -] - [[package]] name = "cpex-tutorial" -version = "0.2.3" +version = "0.3.0" dependencies = [ "async-trait", "cpex", - "cpex-core", - "cpex-sdk", + "praxis-policy-core", "reqwest", "serde_json", "tiny_http", @@ -1094,6 +738,24 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1131,6 +793,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1286,6 +957,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1299,17 +971,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "docker_credential" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" -dependencies = [ - "base64 0.22.1", - "serde", - "serde_json", -] - [[package]] name = "dunce" version = "1.0.5" @@ -1458,16 +1119,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "event-listener" version = "5.4.1" @@ -1495,17 +1146,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ferroid" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" -dependencies = [ - "portable-atomic", - "rand 0.9.4", - "web-time", -] - [[package]] name = "ff" version = "0.13.1" @@ -1522,16 +1162,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1790,12 +1420,12 @@ dependencies = [ ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] @@ -1866,7 +1496,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1874,21 +1503,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", - "winapi", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1905,19 +1519,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1941,21 +1542,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2174,9 +1760,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.4.0" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -2206,7 +1792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ "ascii-canvas", - "bit-set", + "bit-set 0.8.0", "ena", "itertools 0.14.0", "lalrpop-util", @@ -2236,9 +1822,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.9", -] [[package]] name = "libc" @@ -2247,10 +1830,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "libm" -version = "0.2.16" +name = "libyaml-rs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "linked-hash-map" @@ -2270,12 +1853,6 @@ dependencies = [ "linked-hash-map", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -2331,9 +1908,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.1" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" [[package]] name = "lru-slab" @@ -2341,12 +1918,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.2" @@ -2376,12 +1947,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2400,28 +1965,23 @@ dependencies = [ ] [[package]] -name = "mockito" -version = "1.7.2" +name = "moka" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ - "assert-json-diff", - "bytes", - "colored", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "log", - "pin-project-lite", - "rand 0.9.4", - "regex", - "serde_json", - "serde_urlencoded", - "similar", - "tokio", + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] @@ -2468,17 +2028,12 @@ dependencies = [ ] [[package]] -name = "num" -version = "0.4.3" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "num-bigint 0.4.6", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", + "windows-sys 0.61.2", ] [[package]] @@ -2501,31 +2056,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -2541,28 +2071,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint 0.4.6", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2570,7 +2078,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -2641,31 +2148,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "parse-display" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" -dependencies = [ - "parse-display-derive", - "regex", - "regex-syntax", -] - -[[package]] -name = "parse-display-derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "regex-syntax", - "structmeta", - "syn 2.0.118", -] - [[package]] name = "pastey" version = "0.2.3" @@ -2740,43 +2222,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", -] - [[package]] name = "pkcs8" version = "0.9.0" @@ -2839,6 +2290,152 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "praxis-policy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38539da1fc52a1cad1124aba99216eed98d602212f65ea913c37f7c96c6a526" +dependencies = [ + "async-trait", + "bytes", + "http", + "http-body-util", + "hyper-rustls", + "hyper-util", + "praxis-policy-apl-cmf", + "praxis-policy-apl-core", + "praxis-policy-apl-runtime", + "praxis-policy-builtins", + "praxis-policy-core", + "rustls", + "tokio", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "praxis-policy-apl-cmf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c17a247088d1aead6446293fa37affc68cd695e1f24cd93983fc9e0bfb2fae" +dependencies = [ + "chrono", + "praxis-policy-apl-core", + "praxis-policy-core", + "serde_json", +] + +[[package]] +name = "praxis-policy-apl-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa4564e982ddf61d7bb79a1e914a402be3d70f2e849370d1a7051c7f02274be" +dependencies = [ + "async-trait", + "futures", + "praxis-policy-orchestration", + "regex", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "yaml_serde", +] + +[[package]] +name = "praxis-policy-apl-runtime" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94af76898d0400135296c76b0a19a5c3d4c783a9824d3638726b70c012ee195b" +dependencies = [ + "async-trait", + "chrono", + "praxis-policy-apl-cmf", + "praxis-policy-apl-core", + "praxis-policy-core", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "tracing", + "yaml_serde", +] + +[[package]] +name = "praxis-policy-builtins" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514f8817828c4563b523c48e25e81afcfb6b4cd1e9282136f9dff8c13d506601" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.23.1", + "bytes", + "cedar-policy", + "cel", + "chrono", + "deadpool-redis", + "futures", + "getrandom 0.4.3", + "hmac 0.13.0", + "jsonwebtoken", + "moka", + "praxis-policy-apl-core", + "praxis-policy-apl-runtime", + "praxis-policy-core", + "redis", + "regorus", + "serde", + "serde_json", + "sha2 0.11.0", + "stacker", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", + "uuid", + "yaml_serde", + "zeroize", +] + +[[package]] +name = "praxis-policy-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd60a2e3d73a8d139684dcab02afe73bba5e6496f15fbba1986f935b8b4174a4" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "chrono", + "futures", + "hashbrown 0.17.1", + "hmac 0.13.0", + "http", + "praxis-policy-orchestration", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", + "yaml_serde", + "zeroize", +] + +[[package]] +name = "praxis-policy-orchestration" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f194b463d5304f8267f89e3f4ec1c50953ff3ad8bb287186455943bb78b566b" +dependencies = [ + "futures", + "tokio", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2865,6 +2462,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2903,17 +2509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" dependencies = [ "bytes", - "prost-derive 0.10.1", -] - -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive 0.14.4", + "prost-derive", ] [[package]] @@ -2929,19 +2525,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "prost-types" version = "0.10.1" @@ -2949,16 +2532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d0a014229361011dc8e69c8a1ec6c2e8d0f2af7c91e3ea3f5b2170298461e68" dependencies = [ "bytes", - "prost 0.10.4", -] - -[[package]] -name = "prost-types" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" -dependencies = [ - "prost 0.14.4", + "prost", ] [[package]] @@ -3196,9 +2770,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redis" -version = "1.6.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f" +checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9" dependencies = [ "arc-swap", "arcstr", @@ -3283,9 +2857,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regorus" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cc4dc91481b1d4001ba7f2e81f7faf674142e0ac36d37d79e5f02764d06571e" +checksum = "94d90cbdbc4d2900378cf7dbb0c8df85a28e6de07fad75c60cd4d73283eded23" dependencies = [ "anyhow", "chrono", @@ -3305,11 +2879,12 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_yaml", - "spin 0.12.2", + "spin", "thiserror 2.0.20", "url", "uuid", + "vstd", + "yaml_serde", ] [[package]] @@ -3356,7 +2931,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -3393,26 +2968,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8 0.10.2", - "rand_core 0.6.4", - "signature", - "spki 0.7.3", - "subtle", - "zeroize", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3434,26 +2989,12 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -3474,15 +3015,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -3662,17 +3194,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3717,19 +3238,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serdect" version = "0.2.0" @@ -3785,6 +3293,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3811,12 +3328,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "simple_asn1" version = "0.6.4" @@ -3867,12 +3378,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - [[package]] name = "spin" version = "0.12.2" @@ -3935,29 +3440,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "structmeta" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" -dependencies = [ - "proc-macro2", - "quote", - "structmeta-derive", - "syn 2.0.118", -] - -[[package]] -name = "structmeta-derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4017,6 +3499,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -4032,45 +3520,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "testcontainers" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a81ec0158db5fbb9831e09d1813fe5ea9023a2b5e6e8e0a5fe67e2a820733629" -dependencies = [ - "astral-tokio-tar", - "async-trait", - "bollard", - "bytes", - "docker_credential", - "either", - "etcetera", - "ferroid", - "futures", - "itertools 0.14.0", - "log", - "memchr", - "parse-display", - "pin-project-lite", - "serde", - "serde_json", - "serde_with", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tokio-util", - "url", -] - -[[package]] -name = "testcontainers-modules" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e75e78ff453128a2c7da9a5d5a3325ea34ea214d4bf51eab3417de23a4e5147" -dependencies = [ - "testcontainers", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4111,6 +3560,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.53" @@ -4187,7 +3645,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4216,17 +3673,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.19" @@ -4243,43 +3689,33 @@ dependencies = [ ] [[package]] -name = "tonic" -version = "0.14.6" +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ - "async-trait", - "axum", - "base64 0.22.1", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", + "serde_core", ] [[package]] -name = "tonic-prost" -version = "0.14.6" +name = "toml_edit" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ - "bytes", - "prost 0.14.4", - "tonic", + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", ] [[package]] @@ -4290,15 +3726,11 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -4360,6 +3792,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -4429,12 +3887,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.7.1" @@ -4447,33 +3899,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -4484,15 +3909,8 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4501,9 +3919,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4512,12 +3930,83 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "verus_builtin" +version = "0.0.0-2026-08-09-0044" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f2985ebd252c492375e32f2bfcff6e6a04523009a0d624785d3468f3729158" + +[[package]] +name = "verus_builtin_macros" +version = "0.0.0-2026-08-23-0033" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f5b1ed2151955cfe25ebfc1382ab422ae004e5cda6c502871425cc9e2ad5f3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", + "verus_prettyplease", + "verus_syn", +] + +[[package]] +name = "verus_prettyplease" +version = "0.0.0-2026-08-09-0044" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fc115de5fb3806bc362060bb683024660ed2bc13c1183d1f01d464e4537139" +dependencies = [ + "proc-macro2", + "verus_syn", +] + +[[package]] +name = "verus_state_machines_macros" +version = "0.0.0-2026-08-02-0125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f77ff8d121edb1bf2651335769a80a2f611da8573c3b498434a66b3162805f" +dependencies = [ + "indexmap 2.14.0", + "proc-macro2", + "quote", + "verus_syn", +] + +[[package]] +name = "verus_syn" +version = "0.0.0-2026-08-02-0125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17237ea6d267e457d53ce36f55b315fc9edd26eb88c765dd95e035c0b415869" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "vstd" +version = "0.0.0-2026-08-23-0033" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b32ef60c0a4ce7d85691135c15fada7ab84b2c1471322d320fba313190e2e9" +dependencies = [ + "verus_builtin", + "verus_builtin_macros", + "verus_state_machines_macros", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4642,22 +4131,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -4667,12 +4140,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -4888,6 +4355,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -4900,22 +4376,25 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xxhash-rust" version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 99203e31..84bb2a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,32 +6,19 @@ # Workspace root for the CPEX Rust crates. [workspace] -resolver = "2" +resolver = "3" members = [ "crates/cpex", - "crates/cpex-core", "crates/cpex-hosts-python", - "crates/cpex-orchestration", - "crates/cpex-sdk", - "crates/cpex-builtins", "crates/cpex-ffi", - "crates/apl-core", - "crates/apl-cmf", - "crates/apl-cpex", - "builtins/plugins/pii-scanner", - "builtins/plugins/audit-logger", - "builtins/plugins/identity-jwt", - "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", - "builtins/plugins/elicitation-ciba", - "builtins/pdps/cedar-direct", - "builtins/pdps/cel", - "builtins/pdps/opa", - "builtins/session/valkey", "examples/go-demo/ffi", "examples/tutorial", + # Parity harness. Held out of default-members: it replays recorded request + # sets and is run explicitly. + "tests/parity/harness", # PyO3 bindings — excluded from default-members so plain `cargo build` - # stays libpython-independent (KD3). Use `cargo build -p cpex-python` + # stays libpython-independent. Use `cargo build -p cpex-python` # or `maturin develop` to build this crate explicitly. "bindings/python", ] @@ -40,45 +27,28 @@ members = [ # `-p` or `--workspace` flag) picks up. Two members are held out: # # * `bindings/python` — links libpython, so including it would make a plain -# `cargo build` require a Python dev install (KD3). -# * `builtins/session/valkey` — pulls a redis client + a rustls TLS stack, -# which slows every default build. +# `cargo build` require a Python dev install. +# * `tests/parity/harness`: the parity harness, run on demand. # # Everything else, including `crates/cpex-hosts-python` (pure Rust; it only # *spawns* a Python subprocess, never links one), builds by default. # # To exercise a held-out member: # cargo build --workspace # all members -# cargo build -p cpex-session-valkey # just the Valkey session store # cargo build -p cpex-python # just the PyO3 bindings # cargo test --workspace # full sweep (CI) default-members = [ "crates/cpex", - "crates/cpex-core", "crates/cpex-hosts-python", - "crates/cpex-orchestration", - "crates/cpex-sdk", - "crates/cpex-builtins", "crates/cpex-ffi", - "crates/apl-core", - "crates/apl-cmf", - "crates/apl-cpex", - "builtins/plugins/pii-scanner", - "builtins/plugins/audit-logger", - "builtins/plugins/identity-jwt", - "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", - "builtins/plugins/elicitation-ciba", - "builtins/pdps/cedar-direct", - "builtins/pdps/cel", - "builtins/pdps/opa", "examples/go-demo/ffi", "examples/tutorial", ] [workspace.package] -version = "0.2.3" -edition = "2021" +version = "0.3.0" +edition = "2024" # MSRV — keep in sync with rust-toolchain.toml `channel` and clippy.toml `msrv`. rust-version = "1.96" license = "Apache-2.0" @@ -100,23 +70,24 @@ readme = "README.md" # unification where they are actually needed. Dropping `full` removes the # fs/process/signal/io-std surface the embedded library never exercises. tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } -tokio-util = { version = "0.7", features = ["rt"] } serde = { version = "1", features = ["derive", "rc"] } -serde_yaml = "0.9" +# Match PPE's maintained YAML fork while preserving the crate name at call sites. +serde_yaml = { package = "yaml_serde", version = "0.10.4" } serde_json = "1" async-trait = "0.1" -thiserror = "2" tracing = "0.1" uuid = { version = "1", features = ["v4", "serde"] } -paste = "1" -futures = "0.3" -hashbrown = "0.17" -arc-swap = "1.7" -wildmatch = "2" rmp-serde = "1" serde_bytes = "0.11" chrono = { version = "0.4", features = ["serde"] } -regex = "1" + +# Policy engine. Pinned exactly, not by caret: PPE has shipped breaking changes +# between releases, so moving the pin is a deliberate step that reruns the +# parity suite. All PPE crates move together. `praxis-policy` is the facade `cpex` +# re-exports; kept crates that `cpex` itself depends on use +# `praxis-policy-core` directly so there is no dependency cycle. +praxis-policy = { version = "=0.5.0", default-features = false } +praxis-policy-core = { version = "=0.5.0" } # Internal workspace crates — single source of truth for their path + version # requirement. Members depend on these via `{ workspace = true }` so a version @@ -125,24 +96,9 @@ regex = "1" # 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-core = { path = "crates/cpex-core", version = "0.2.3" } -cpex-orchestration = { path = "crates/cpex-orchestration", version = "0.2.3" } -cpex-sdk = { path = "crates/cpex-sdk", version = "0.2.3" } -cpex-builtins = { path = "crates/cpex-builtins", version = "0.2.3", default-features = false } -apl-core = { path = "crates/apl-core", version = "0.2.3" } -apl-cmf = { path = "crates/apl-cmf", version = "0.2.3" } -apl-cpex = { path = "crates/apl-cpex", version = "0.2.3" } -cpex-plugin-pii-scanner = { path = "builtins/plugins/pii-scanner", version = "0.2.3" } -cpex-plugin-audit-logger = { path = "builtins/plugins/audit-logger", version = "0.2.3" } -cpex-plugin-identity-jwt = { path = "builtins/plugins/identity-jwt", version = "0.2.3" } -cpex-plugin-delegator-oauth = { path = "builtins/plugins/delegator-oauth", version = "0.2.3" } -cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.2.3" } -cpex-plugin-elicitation-ciba = { path = "builtins/plugins/elicitation-ciba", version = "0.2.3" } -cpex-pdp-cedar-direct = { path = "builtins/pdps/cedar-direct", version = "0.2.3" } -cpex-pdp-cel = { path = "builtins/pdps/cel", version = "0.2.3" } -cpex-pdp-opa = { path = "builtins/pdps/opa", version = "0.2.3" } -cpex-session-valkey = { path = "builtins/session/valkey", version = "0.2.3" } -cpex-hosts-python = { path = "crates/cpex-hosts-python", version = "0.2.3", default-features = false } +cpex = { path = "crates/cpex", version = "0.3.0" } +cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.3.0" } +cpex-hosts-python = { path = "crates/cpex-hosts-python", version = "0.3.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 @@ -151,7 +107,7 @@ cpex-hosts-python = { path = "crates/cpex-hosts-python", v # trade build time for a materially smaller archive. # # panic = "abort" is intentionally NOT set: cpex-ffi catches panics at its -# `#[no_mangle]` boundary with `catch_unwind`, and aborting would turn a +# `#[unsafe(no_mangle)]` boundary with `catch_unwind`, and aborting would turn a # recoverable policy panic into a host-process kill. Revisit only if that # boundary is reworked to not rely on unwinding. [profile.release] @@ -178,6 +134,10 @@ strip = true # drop symbols + debug info from the artifact # (most are `cargo clippy --fix`-able one lint at a time). [workspace.lints.rust] # --- Enforced --- +# A renamed or removed feature leaves its `cfg(feature = ...)` gates silently +# false, which drops whatever they guard. Denying unknown cfgs turns that into a +# build error at the moment of the rename. +unexpected_cfgs = "deny" unused_imports = "deny" unused_variables = "deny" trivial_numeric_casts = "deny" diff --git a/Makefile b/Makefile index b3c70c01..b297589c 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,9 @@ help: @echo " test-all Rust tests + Go tests (with -race)" @echo " test-python-e2e Python host e2e (#[ignore]d); needs CPEX_PYTHON_SOURCE." @echo " Skips FAIL here — this lane must really run." + @echo " parity Replay the 0.2.3 snapshots (cpex-parity-harness)." + @echo " Set PARITY_VALKEY_URL to include Valkey; skips then fail." + @echo " test-acceptance Python bindings + Go acceptance suites" @echo "" @echo "Supply chain & coverage:" @echo " audit cargo deny check (advisories, licenses, bans, sources)" @@ -57,16 +60,22 @@ help: @echo " docs-clean Remove generated documentation artifacts" @echo "" @echo "Go bindings (go/cpex):" + @echo " ffi-release Build the cpex-ffi release library the Go package links" @echo " go-build go-test go-test-race go-fmt go-vet go-lint-check go-lint-fix" + @echo " go-test-acceptance Go acceptance tests (TestAcceptance*), with -race" + @echo " GO_STATIC=1 removes the shared library first, so Go" + @echo " links libcpex_ffi.a as release consumers do (CI sets it)" @echo "" @echo "Python bindings (bindings/python — requires maturin):" @echo " bindings-python-build Build and install Python bindings (debug)" @echo " bindings-python-build-release Build Python bindings wheel (release)" @echo " bindings-python-test Build + run Python binding tests" + @echo " bindings-python-smoke Build the wheel, install it in a fresh venv, run a policy" + @echo " bindings-python-acceptance Build + run the Python acceptance tests" @echo "" @echo "Examples:" @echo " examples-build Build all Rust + Go examples (catches stale APIs)" - @echo " examples-run Run all examples end-to-end" + @echo " examples-run Run the tutorial (no-IdP) checks and the Go demos" @echo "" @echo "End-to-end:" @echo " ci Lint + tests + examples-build (CI gate)" @@ -118,6 +127,7 @@ lint: @echo "🦀 fmt --check + clippy -D warnings ..." @$(CARGO) fmt --all -- --check @$(CARGO) clippy --workspace --all-targets -- -D warnings + @$(CARGO) clippy -p cpex --all-targets --all-features -- -D warnings @echo "✅ lint passed" # Developer convenience: format, then apply clippy's machine-applicable fixes. @@ -140,6 +150,7 @@ machete: .PHONY: test test: @$(CARGO) test --workspace + @$(CARGO) test -p cpex --features python-host .PHONY: test-ffi test-ffi: @@ -156,7 +167,8 @@ test-ffi: # # CPEX_PYTHON_SOURCE must point at a cpex Python checkout carrying # cpex/framework/isolated/worker.py (PyPI's is behind this branch). -PYTHON_E2E_TESTS = credential_e2e isolated_venv_e2e extensions_merge_e2e +# config_e2e is left out: it needs cpex-test-plugin installed under plugins/. +PYTHON_E2E_TESTS ?= credential_e2e isolated_venv_e2e extensions_merge_e2e acceptance_e2e .PHONY: test-python-e2e test-python-e2e: @@ -181,6 +193,36 @@ test-python-e2e: .PHONY: test-all test-all: test go-test-race +# Parity with the 0.2.3 baseline: replays every recorded scenario through the +# current engine and diffs it against tests/parity/snapshots. See +# tests/parity/README.md. +# +# Valkey scenarios and the stored-label read-back need a throwaway Valkey (the +# harness runs FLUSHDB on it): +# docker run -d --rm -p 127.0.0.1:6391:6379 valkey/valkey:8-alpine +# make parity PARITY_VALKEY_URL=redis://127.0.0.1:6391/0 +# Without PARITY_VALKEY_URL those scenarios are skipped. With it set, any +# skip fails the target, so a lane that is meant to have Valkey cannot pass +# without it. +PARITY_LOG = target/parity-test.log + +.PHONY: parity +parity: + @mkdir -p target + @echo "🔁 parity against the 0.2.3 snapshots ..." + @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:"; \ + grep 'SKIPPED' $(PARITY_LOG); exit 1; fi + @echo "✅ parity passed" + +# Acceptance suites that live beside each surface's own tests: the Python +# bindings (tests/test_acceptance.py) and the Go package (TestAcceptance*). +# The Python host's acceptance coverage runs in test-python-e2e. +.PHONY: test-acceptance +test-acceptance: bindings-python-acceptance go-test-acceptance + # ============================================================================= # Supply chain & coverage # ============================================================================= @@ -252,28 +294,65 @@ bindings-python-test: bindings-python-build @cd $(PYTHON_BINDINGS_DIR) && $(VENV_BIN)/pytest tests/ -v @echo "✅ Python binding tests passed" +.PHONY: bindings-python-acceptance +bindings-python-acceptance: bindings-python-build + @echo "🧪 Running Python binding acceptance tests..." + @cd $(PYTHON_BINDINGS_DIR) && $(VENV_BIN)/pytest tests/test_acceptance.py -v + @echo "✅ Python binding acceptance tests passed" + +# Builds a release wheel, installs it into a fresh venv and runs a policy from +# outside the source tree. Needs only python3; the script installs maturin into +# a throwaway venv when it is not on PATH. +PYTHON ?= python3 + +.PHONY: bindings-python-smoke +bindings-python-smoke: + @echo "🐍 Python wheel smoke test..." + @$(PYTHON) $(PYTHON_BINDINGS_DIR)/tests/smoke_wheel.py + # ============================================================================= # Go bindings (go/cpex) # ============================================================================= # -# go/cpex links against the cpex-ffi cdylib at target/release. Go targets -# ensure the release build is current first — Go's linker errors on a missing -# libcpex_ffi are easy to misread. +# go/cpex links -lcpex_ffi from target/release. Go targets build cpex-ffi in +# release mode first, since Go's linker errors on a missing libcpex_ffi are +# easy to misread. +# +# cpex-ffi builds both a shared and a static library, and the linker prefers +# the shared one when both are present. GO_STATIC=1 deletes the shared library +# after the build so Go links libcpex_ffi.a, the way consumers of the release +# artifacts do. That also checks the native link flags in go/cpex/ffi.go. CI +# sets it; locally the default keeps the shared library. +GO_STATIC ?= +FFI_LIB_DIR = target/release + +.PHONY: ffi-release +ffi-release: + @$(CARGO) build --release -p cpex-ffi + @if [ "$(GO_STATIC)" = "1" ]; then \ + rm -f $(FFI_LIB_DIR)/libcpex_ffi.so $(FFI_LIB_DIR)/libcpex_ffi.dylib; \ + echo "→ GO_STATIC=1: Go links $(FFI_LIB_DIR)/libcpex_ffi.a"; fi .PHONY: go-build -go-build: build-release +go-build: ffi-release @cd $(GO_DIR) && $(GO) build ./... .PHONY: go-test -go-test: build-release +go-test: ffi-release @cd $(GO_DIR) && $(GO) test -count=1 ./... .PHONY: go-test-race -go-test-race: build-release +go-test-race: ffi-release @cd $(GO_DIR) && $(GO) test -count=1 -race ./... +# Go acceptance tests follow the TestAcceptance* naming. go-test and +# go-test-race already run them along with the rest of the package. +.PHONY: go-test-acceptance +go-test-acceptance: ffi-release + @cd $(GO_DIR) && $(GO) test -count=1 -race -run '^TestAcceptance' -v ./... + .PHONY: go-vet -go-vet: build-release +go-vet: ffi-release @cd $(GO_DIR) && $(GO) vet ./... .PHONY: go-fmt @@ -281,13 +360,13 @@ go-fmt: @cd $(GO_DIR) && $(GO) fmt ./... .PHONY: go-lint-fix -go-lint-fix: build-release +go-lint-fix: ffi-release @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ echo "❌ golangci-lint not found (brew install golangci-lint)"; exit 1; } @cd $(GO_DIR) && $(GO) fmt ./... && $(GO) vet ./... && $(GOLANGCI_LINT) run --fix ./... .PHONY: go-lint-check -go-lint-check: build-release +go-lint-check: ffi-release @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ echo "❌ golangci-lint not found (brew install golangci-lint)"; exit 1; } @cd $(GO_DIR) && unformatted=$$(gofmt -l .); \ @@ -306,18 +385,23 @@ go-lint-check: build-release rust-examples-build: @$(CARGO) build --examples --workspace +# The Go demo links both its factory staticlib and the CPEX Go SDK's FFI library. +.PHONY: go-examples-ffi +go-examples-ffi: ffi-release + @$(CARGO) build --release -p cpex-demo-ffi + .PHONY: go-examples-build -go-examples-build: build-release +go-examples-build: go-examples-ffi @cd $(GO_EXAMPLES_DIR) && $(GO) build ./... .PHONY: examples-build examples-build: rust-examples-build go-examples-build @echo "✅ All examples built" +# The tutorial modules are the Rust examples; tutorial-check-local runs the +# ones that need no IdP. .PHONY: examples-run examples-run: examples-build tutorial-check-local - @$(CARGO) run --example plugin_demo -p cpex-core --quiet >/dev/null - @$(CARGO) run --example cmf_capabilities_demo -p cpex-core --quiet >/dev/null @cd $(GO_EXAMPLES_DIR) && $(GO) run . >/dev/null @cd $(GO_EXAMPLES_DIR) && $(GO) run ./cmd/cmf-demo >/dev/null @echo "✅ All examples ran successfully" @@ -442,14 +526,16 @@ release-version: release-tool release: release-tool @$(CARGO) release $(RELEASE_ARG) --workspace --no-publish --no-push --execute -# Build + verify a .crate for every crates.io-published member without -# uploading — the same check the release workflow's dry-run runs. The three +# Build + verify a .crate for every crates.io-published member (cpex, +# cpex-hosts-python, cpex-plugin-delegator-biscuit, cpex-python) without +# uploading — the same check the release workflow's dry-run runs. The four # `publish = false` crates are excluded (cpex-ffi ships as signed prebuilt -# artifacts; cpex-demo-ffi and cpex-tutorial are examples). CI runs this on a -# clean checkout; --allow-dirty lets you run it locally with work in progress. +# artifacts; cpex-demo-ffi and cpex-tutorial are examples; cpex-parity-harness +# is a test harness). CI runs this on a clean checkout; --allow-dirty lets you +# run it locally with work in progress. .PHONY: publish-dry publish-dry: - @$(CARGO) package --workspace --locked --allow-dirty --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial + @$(CARGO) package --workspace --locked --allow-dirty --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial --exclude cpex-parity-harness # Tag the current commit across the three namespaces the project releases on, # then push all three. The `v` tag is what the CI release workflow diff --git a/README.md b/README.md index a5c36d41..12074b54 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ [![MSRV](https://img.shields.io/badge/MSRV-1.96-blue.svg)](rust-toolchain.toml) > [!NOTE] -> **Looking for CPEX Python?** It now lives on the [`0.1.x` branch](https://github.com/contextforge-org/cpex/tree/0.1.x), maintained for backwards compatibility. `main` (`0.2`+) is the **Rust** framework, re-architected around policy and authorization. Python bindings (PyO3) over the Rust core are coming. +> **Looking for the pure-Python CPEX framework?** It lives on the [`0.1.x` branch](https://github.com/contextforge-org/cpex/tree/0.1.x), maintained for backwards compatibility. `main` (`0.2`+) is the policy-first runtime, with Rust, Python and Go APIs. +> +> **Upgrading from 0.2?** CPEX 0.3 runs on the [praxis-policy engine](https://github.com/praxis-proxy/policy) (PPE) and renames the API, hook names and several config keys. See the [migration guide](https://contextforge-org.github.io/cpex/docs/migration-0.3/). ## What's CPEX? @@ -33,7 +35,7 @@ Each capability exposed to an agent defines its own enforcement pipeline, whethe APL is the configuration that defines that pipeline. A _route_ identifies an entity and sequences the controls that protect it: resolve identity, consult an external PDP, exchange credentials, invoke plugins, redact results, propagate taint, and audit the operation. Each step executes deterministically, in order, with only the context it explicitly declares. -Policies execute in two phases—before the operation and after its result—making the complete enforcement pipeline easy to inspect and reason about. +Policies execute in two phases, before the operation and after its result, making the complete enforcement pipeline easy to inspect and reason about. ## Context-aware enforcement @@ -46,6 +48,9 @@ The example below places CPEX between a single agent and three backends: HR reco One policy defines three distinct enforcement pipelines, one for each entity. ```yaml +global: + authentication: [jwt-user] # resolve the caller's identity on every route + routes: # HR lookup: gate on role, scope a downstream token, redact by permission, taint the session. - tool: get_compensation @@ -74,7 +79,6 @@ routes: authorization: pre_invocation: - "require(perm.email_send)" - - "run(pii-scan)" - "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')" - "run(audit-log)" ``` @@ -83,7 +87,7 @@ Two examples illustrate the behavior: - **Same request, different result.** An HR analyst with `view_ssn` receives the full record. Without that permission, the SSN is redacted before the response leaves CPEX. Engineers never reach the backend because the request is rejected by `require(role.hr)`. -- **Information follows the session.** Reading compensation data taints the session. Later attempts to send external email are blocked—even if the email itself contains no sensitive content. +- **Information follows the session.** Reading compensation data taints the session. Later attempts to send external email are blocked, even if the email itself contains no sensitive content. The application stays the same. Only the policy changes. @@ -116,28 +120,42 @@ It can run in front of tool servers, beside an agent as an egress sidecar, insid ## Install ```bash -# Engine only (bring your own plugins): -cargo add cpex - -# With the bundled builtin plugins/PDPs: -cargo add cpex --features builtins +cargo add cpex # Rust +pip install maturin && (cd bindings/python && maturin develop) # Python, from a checkout +go get github.com/contextforge-org/cpex/go/cpex # Go (links the signed libcpex_ffi artifact) ``` -The default `cpex = "0.2"` is the **engine alone**. Opt into the bundled extension set with the `builtins` feature, everything (incl. the Valkey session store) with `full`, or a granular subset: `jwt`, `oauth`, `pii`, `audit`, `cedar`, `cel`, `valkey`. +`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. ```rust use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; + +let engine = Arc::new(PolicyEngine::default()); +// Register every builtin factory and install the APL config visitor: +cpex::install_builtins(&engine); +// Builtins that call out (JWKS, token exchange, CIBA, Vault) borrow this +// transport. It refuses private and loopback destinations by default. +cpex::install_default_http_transport(&engine); +engine.load_config_yaml(yaml)?; +engine.initialize().await?; +``` -let mgr = Arc::new(PluginManager::default()); +```python +from cpex import PolicyEngine + +engine = PolicyEngine("policy.yaml") # builtins, transport and secret providers installed +await engine.initialize() +``` -// With a builtins feature enabled, register every enabled builtin factory -// and install the APL config visitor in one call: -cpex::install_builtins(&mgr); -// ... then load an APL config that references the enabled plugin `kind`s. +```go +engine, _ := cpex.NewPolicyEngineDefault() +engine.EnableAPL() // builtins, transport and secret providers +engine.LoadConfig(yaml) +engine.Initialize() ``` -Authoring plugins or PDP resolvers? Depend on the lean [`cpex-sdk`](crates/cpex-sdk) crate instead of the full runtime. See [`crates/cpex-core/examples`](crates/cpex-core/examples) for runnable examples. +`run(audit-log)` in the example above names a host plugin. CPEX ships no audit or PII-scanner plugin of its own; see [Builtins](https://contextforge-org.github.io/cpex/docs/builtins/). Writing a plugin? Implement it against `cpex::prelude` and register it with `engine.register_factory`; the [tutorial](examples/tutorial) shows how. ## Documentation @@ -147,23 +165,23 @@ Authoring plugins or PDP resolvers? Depend on the lean [`cpex-sdk`](crates/cpex- - [**Use Cases**](https://contextforge-org.github.io/cpex/docs/use-cases/): complete integration examples with external middleware projects. - [**Tutorial**](https://contextforge-org.github.io/cpex/docs/tutorial/): a complete tour of the framework. - [**APL**](https://contextforge-org.github.io/cpex/docs/apl/): the policy pipeline configuration: predicates, effects, sequencing, field pipelines. +- [**Migrating to 0.3**](https://contextforge-org.github.io/cpex/docs/migration-0.3/): every API, hook and config change from 0.2.x. ## Workspace layout -CPEX is a Cargo workspace of focused crates: +The engine, the APL compiler and runtime, and the builtins come from the [praxis-policy](https://github.com/praxis-proxy/policy) crates on crates.io, pinned to an exact version. This repository holds what CPEX adds: -| Crate | Description | -|-------|-------------| -| [`cpex`](crates/cpex) | Host facade, re-exports the runtime and (optionally) the builtins | -| [`cpex-core`](crates/cpex-core) | Plugin runtime: `PluginManager`, executor, hooks, config | -| [`cpex-sdk`](crates/cpex-sdk) | Plugin author SDK: `Plugin`/`HookHandler` traits, payloads, results | -| [`cpex-orchestration`](crates/cpex-orchestration) | Async concurrency primitives shared by the runtime | -| [`cpex-builtins`](crates/cpex-builtins) | Feature-gated bundle of builtin plugins, PDPs, session stores | -| [`cpex-ffi`](crates/cpex-ffi) | C FFI (`cdylib`/`staticlib`) for Go / Python / WASM host bindings | -| [`apl-core`](crates/apl-core) · [`apl-cmf`](crates/apl-cmf) · [`apl-cpex`](crates/apl-cpex) | APL (Authorization Policy Language): compiler/evaluator, CMF bridge, CPEX integration | -| `builtins/*` | Bundled plugins (PII scanner, audit logger, JWT identity, OAuth/Biscuit delegation), PDPs (Cedar, CEL, OPA/Rego), and the Valkey session store | +| Path | Description | +|------|-------------| +| [`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 | +| [`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 | +| [`examples/tutorial`](examples/tutorial), [`examples/go-demo`](examples/go-demo) | Runnable tutorial modules and Go demos | -The C FFI is distributed as signed prebuilt artifacts. See [`crates/cpex-ffi/RELEASE.md`](crates/cpex-ffi/RELEASE.md). Go bindings live in [`go/cpex`](go/cpex). +The 0.2.x crates (`cpex-core`, `cpex-sdk`, `cpex-builtins`, `apl-core`, the `cpex-plugin-*` and `cpex-pdp-*` crates, and others) are no longer built; their 0.2.3 releases stay on crates.io. The C FFI is distributed as signed prebuilt artifacts. See [`crates/cpex-ffi/RELEASE.md`](crates/cpex-ffi/RELEASE.md). ## Development diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 9a9a22bb..6c041d52 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -7,7 +7,7 @@ # # Built via maturin (`maturin develop` / `maturin build`). # The `extension-module` feature is opt-in (non-default) so the pure-Rust -# workspace build stays libpython-independent (KD3). maturin activates it +# workspace build stays libpython-independent. maturin activates it # automatically via `[features] -> pyo3/extension-module` in pyproject.toml. # # Build this crate directly (macOS, Python present): @@ -17,29 +17,25 @@ [package] name = "cpex-python" -description = "PyO3 bindings for the CPEX plugin runtime" +description = "PyO3 bindings for the CPEX policy engine" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true +repository.workspace = true +homepage.workspace = true +rust-version.workspace = true [lib] name = "_lib" crate-type = ["cdylib"] [dependencies] -cpex-core = { workspace = true } - -# The builtin extension set — mirrors the feature set shipped by cpex-ffi. -cpex-builtins = { workspace = true, default-features = false, features = [ - "pii-scanner", - "audit-logger", - "identity-jwt", - "delegator-oauth", - "cedar-direct", - "cel", - "valkey", -] } +praxis-policy-core = { workspace = true } + +# The CPEX facade: the builtin extension set, the HTTP transport and the host +# wiring shared with cpex-ffi (`cpex::host`). +cpex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -58,3 +54,6 @@ tokio = { workspace = true, features = ["rt", "macros"] } [features] default = [] + +[lints] +workspace = true diff --git a/bindings/python/MIGRATION.md b/bindings/python/MIGRATION.md index 40a31663..5bd6dc46 100644 --- a/bindings/python/MIGRATION.md +++ b/bindings/python/MIGRATION.md @@ -34,9 +34,19 @@ from cpex import PluginManager, PipelineResult ## Hook Names -Hook names are identical (`cmf.tool_pre_invoke`, etc.). The Rust-backed -package routes `cmf.*` hooks through `MessagePayload` and all other hook -names through `GenericPayload` — no hook names raise `ValueError`. +Hook names follow the engine (`cmf.tool_pre_invoke`, `identity.resolve`, +`token.delegate`, etc.). A name removed in 0.3 (`tool_pre_invoke`, +`identity_resolve`, `prompt_pre_fetch` and the rest) raises `ValueError` +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 +two typed cases, a dict that doesn't match the schema raises `ValueError`. + +An `identity.resolve` payload may set only `source`, `source_header`, +`headers`, `client_host` and `client_port`; one that sets a resolver +output field (`subject`, `raw_claims` and the like) raises `ValueError`. +The result's `modified_payload` does not echo `headers`. ## Payload Shape @@ -94,10 +104,10 @@ side effects in tests, always call shutdown first. The following features from the legacy package are **not available in v1** of the Rust-backed package: -- **Typed identity/delegation payloads** (`identity_resolve`, - `token_delegate`): these hooks route through `GenericPayload` in v1. - The raw dict is preserved; typed constructors and token injection are - deferred to v2. +- **Delegation** (`token_delegate`, now `token.delegate`): inbound tokens + are never serialized, so a delegator cannot exchange them for a call + made from Python. `identity.resolve` (formerly `identity_resolve`) does + take a typed `IdentityPayload` dict; see the README. - **Dual-mode / backend selection**: this package is always Rust-backed. There is no environment variable to switch between backends. diff --git a/bindings/python/README.md b/bindings/python/README.md index 959c0b77..bc463fbc 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -1,11 +1,12 @@ -# cpex — Rust-backed Python bindings +# cpex: Rust-backed Python bindings -A native Python extension wrapping the `cpex-core` Rust runtime via PyO3. -Provides the canonical CPEX plugin lifecycle with `await`-based async APIs. +A native Python extension wrapping the Praxis Policy Engine (PPE) Rust +runtime via PyO3. Provides the CPEX policy engine lifecycle with +`await`-based async APIs. ## Requirements -- Python ≥ 3.10 +- Python 3.10 or later - Rust toolchain (`rustup`) - [maturin](https://github.com/PyO3/maturin) (`pip install maturin`) @@ -24,20 +25,29 @@ maturin develop ```python import asyncio -from cpex import PluginManager +from cpex import PolicyEngine async def main(): - mgr = PluginManager("plugins/config.yaml") - await mgr.initialize() + engine = PolicyEngine("policy.yaml") + await engine.initialize() - result = await mgr.invoke_hook( + result = await engine.invoke_hook( "cmf.tool_pre_invoke", { "message": { - "role": "user", - "content": [{"type": "text", "text": "Hello"}], + "role": "assistant", + "content": [{ + "content_type": "tool_call", + "content": { + "tool_call_id": "tc_1", + "name": "lookup_person", + "arguments": {"name": "Alice"}, + }, + }], } }, + # Under the default `dispatch: policy`, meta selects the route. + {"meta": {"entity_type": "tool", "entity_name": "lookup_person"}}, ) if not result.continue_processing: @@ -46,53 +56,131 @@ async def main(): print("Allowed") # Always shut down to drain fire-and-forget tasks. - await mgr.shutdown() + await engine.shutdown() asyncio.run(main()) ``` ## API -### `PluginManager(config_path: str)` +`cpex` exports `PolicyEngine`, `PipelineResult` and `PluginManager`. +`PluginManager` is the pre-0.3 name, kept as an alias: `PluginManager is +PolicyEngine`. -Synchronous constructor. Reads the YAML config file, registers bundled APL -factories, and loads the config. Raises `ValueError` on missing file, -IO error, or config parse failure. +### `PolicyEngine(config_path: str, *, allow_private_destinations: bool = False)` -### `await manager.initialize()` +Synchronous constructor. Installs the bundled builtins, one outbound HTTP +transport and the secret providers, then reads and loads the YAML config. +Raises `ValueError` on missing file, IO error, or config error (including +an unknown plugin `kind` or a removed hook name). -Initialize all registered plugins. Must be called before `invoke_hook`. +Bundled builtins: plugin kinds `identity/jwt`, `identity/api-key`, +`delegator/oauth`, `delegator/vault` and `elicitation/ciba`; PDP kinds `cedar-direct`, `cel` +and `opa`; session store kind `valkey`; secret provider kinds `env`, +`file` and `vault`. Builtins that make HTTP calls must declare +`capabilities: [perform_http]`. -### `await manager.shutdown()` +`allow_private_destinations` lets those builtins reach private, loopback +and link-local addresses (a JWKS endpoint on localhost, an in-cluster +Vault). It is off by default, which refuses such requests; the builtin +then reports its egress-denied code (`delegation.egress_denied`, +`elicitation.egress_denied`, or `auth.jwks_unavailable` for a JWKS fetch). +It is a host setting: no policy document can turn it on. Keyword only. + +### `await engine.initialize()` + +Initialize all registered plugins and resolve declared secrets. The call +schedules work immediately; await its result before `invoke_hook`. + +Raises `RuntimeError` once `shutdown()` has been called, including when +shutdown races with scheduled initialization. Raises +`TimeoutError` past the wall-clock timeout. A timed-out initialize keeps +running; once it ends, the engine shuts down to stop the plugins it +started. The engine is terminal from the moment the timeout fires, so +build a new `PolicyEngine` to try again. + +### `await engine.shutdown()` Shut down all plugins and drain fire-and-forget background tasks. **Call this before exit if you need fire-and-forget side effects to complete.** +Calling `shutdown()` starts teardown and immediately refuses new work. +Await its result to know when teardown has finished. The shutdown task +continues if its awaitable is dropped or cancelled. It waits for an +`initialize()` in flight, then tears down the plugins it started. -### `await manager.invoke_hook(hook_name, payload, extensions=None, context_table=None)` +Shutdown is terminal from the call: later `initialize`, +`invoke_hook` and `refresh_secrets` calls raise `RuntimeError`. A second +`shutdown` after one completed is a no-op. A shutdown that raised +`TimeoutError` can be retried; the retry waits for the first to finish +and reports its outcome. + +### `await engine.invoke_hook(hook_name, payload, extensions=None, context_table=None)` Invoke a hook by name. Returns a `PipelineResult`. -- `hook_name` — e.g. `"cmf.tool_pre_invoke"`. Any hook name is accepted; - `cmf.*` hooks use typed `MessagePayload`, others use `GenericPayload`. -- `payload` — JSON-compatible `dict` (str keys, depth ≤ 128). -- `extensions` — optional `dict` of CPEX extensions fields. -- `context_table` — optional `dict` for stateful plugins. +- `hook_name`: e.g. `"cmf.tool_pre_invoke"`. `cmf.*` hooks use typed + `MessagePayload`, `identity.resolve` uses typed `IdentityPayload`, + others use `GenericPayload`. A hook name removed in 0.3 + (`tool_pre_invoke`, `identity_resolve` and the rest) raises + `ValueError` naming its replacement. +- `payload`: JSON-compatible `dict` (str keys, depth at most 128). +- `extensions`: optional `dict` of CPEX extensions fields. Under the + 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. **Raises:** -- `ValueError` — payload conversion failure or config error. -- `RuntimeError` — plugin execution error. -- `TimeoutError` — wall-clock timeout exceeded (60 s default). +- `ValueError`: a hook name removed in 0.3; an `identity.resolve` + payload that sets a field other than `source`, `source_header`, + `headers`, `client_host` and `client_port`; payload conversion failure; + or config error. +- `RuntimeError`: plugin execution error, or a call after `shutdown`. +- `TimeoutError`: wall-clock timeout exceeded (60 s default). + +Policy denials **do not raise**; check `result.continue_processing`. + +### `await engine.refresh_secrets()` + +Re-read every declared secret, renewing a Vault token when due. PPE never +re-reads on its own, so a host that rotates secrets calls this on its own +schedule. Returns a dict with the same fields as the C FFI report: + +```python +{ + "updated": ["api_key"], # values whose bytes changed + "unchanged": 0, # values re-read and found unchanged + "failed": [ # values that could not be re-read + {"secret": "...", "provider": "...", "kind": "not_found", "message": "..."}, + ], +} +``` + +`kind` is one of `config`, `reference`, `not_found`, `malformed`, `backend` +or `other`. A failed value keeps its last-good bytes; failures are listed, +not raised. `message` never contains the secret. + +Raises `RuntimeError` after `shutdown()` or when a secret provider +panics, and `TimeoutError` past the wall-clock timeout. -Policy denials **do not raise** — check `result.continue_processing`. +### `engine.secrets_last_success(provider: str) -> datetime | None` + +When `provider` last re-read all of its values without a failure, as a +timezone-aware UTC `datetime`. Failed refreshes retain that timestamp; +inspect the refresh report's `failed` entries to detect an outage. `None` when +there has been no successful refresh, the provider is undeclared, or secrets +are not resolved yet. +Initialization counts as the first success. Synchronous. ### `PipelineResult` | Attribute | Type | Description | |-----------|------|-------------| -| `continue_processing` | `bool` | `False` when a plugin denied | +| `continue_processing` | `bool` | `False` when a plugin or policy denied | | `violation` | `dict \| None` | Populated on deny | | `errors` | `list[dict]` | Per-plugin errors (on_error: ignore/disable) | | `modified_payload` | `dict \| None` | Payload after transform-phase modifications | +| `payload_modified` | `bool` | Whether a modification was accepted | | `modified_extensions` | `dict \| None` | Extensions after modifications | | `metadata` | `dict \| None` | Optional telemetry metadata | | `context_table` | `dict` | Per-plugin state | @@ -100,14 +188,14 @@ Policy denials **do not raise** — check `result.continue_processing`. ## Shutdown Contract (Fire-and-Forget Tasks) Plugins configured with `mode: fire_and_forget` run asynchronously. -Their side effects are **not guaranteed** until `await manager.shutdown()` +Their side effects are **not guaranteed** until `await engine.shutdown()` completes. In tests always await `shutdown()` before asserting fire-and-forget side effects: ```python -await mgr.invoke_hook(...) -await mgr.shutdown() # drain before asserting audit log -assert audit_log_written() +await engine.invoke_hook(...) +await engine.shutdown() # drain before asserting side effects +assert side_effect_happened() ``` ## Isolated Virtualenv Requirement @@ -126,14 +214,41 @@ CPEX_PY_WORKER_THREADS=4 python my_script.py Defaults to tokio's `num_cpus` when unset. -## v1 Deferred Features +## Deferred Features + +- **Delegation** (`delegate(...)` steps, `token.delegate`): the inbound + tokens a delegator exchanges are never serialized, so they cannot cross + from an `identity.resolve` result into a later tool call. Drive + delegation from Rust, or from Go with `InvokeResolved`. + +### Identity from Python + +`identity.resolve` takes a dict shaped like PPE's `IdentityPayload`. Pass +the request's credential headers with lowercased names; the resolved +subject comes back under `modified_payload["subject"]`. Put it in the tool +call's extensions as `security.subject`: + +```python +ident = await engine.invoke_hook( + "identity.resolve", + {"source": "bearer", "headers": {"authorization": f"Bearer {token}"}}, + {"meta": {"entity_type": "tool", "entity_name": "get_compensation"}}, +) +if ident.continue_processing: + ext = { + "meta": {"entity_type": "tool", "entity_name": "get_compensation"}, + "security": {"subject": ident.modified_payload["subject"]}, + } + result = await engine.invoke_hook("cmf.tool_pre_invoke", payload, ext) +``` -- **Identity/delegation hooks** (`identity_resolve`, `token_delegate`): - routed through `GenericPayload` in v1 — token fields are preserved in - the raw dict but are not cryptographically validated. Typed constructors - and token injection are planned for v2. -- **Cedarling PDP**: available behind the `cedarling` Cargo feature - (`--features cedarling`), off by default. +The input may set only `source`, `source_header`, `headers`, +`client_host` and `client_port`; a resolver output field such as +`subject` raises `ValueError`. `modified_payload` does not echo the +request `headers`, since they carry the inbound token. + +The engine trusts the subject it is given, so only pass one that came +from `identity.resolve`. ## Running Tests @@ -143,6 +258,17 @@ maturin develop pytest tests/ -v ``` +Or from the repository root: `make bindings-python-test`. + +Wheel smoke test (builds a release wheel, installs it into a fresh venv, +imports `cpex` there and runs a policy): + +```bash +python bindings/python/tests/smoke_wheel.py +# or test a wheel built elsewhere: +python bindings/python/tests/smoke_wheel.py --wheel target/wheels/cpex-*.whl +``` + ## Migration See [MIGRATION.md](MIGRATION.md) for a mapping from the legacy diff --git a/bindings/python/python/cpex/__init__.py b/bindings/python/python/cpex/__init__.py index e137dc5e..6c2503ef 100644 --- a/bindings/python/python/cpex/__init__.py +++ b/bindings/python/python/cpex/__init__.py @@ -3,10 +3,15 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Ted Habeck # -# Rust-backed cpex package — re-exports from the native extension module. +# Rust-backed cpex package: re-exports from the native extension module. # # Import from here, not from cpex._lib directly. -# No import-time side effects beyond loading the native extension (R4, #7). -from cpex._lib import PipelineResult, PluginManager +# No import-time side effects beyond loading the native extension. +# +# `PluginManager` is the pre-0.3 name of `PolicyEngine`, kept as an alias +# so existing callers keep working. Both names are the same class. +from cpex._lib import PipelineResult, PolicyEngine + +PluginManager = PolicyEngine -__all__ = ["PluginManager", "PipelineResult"] +__all__ = ["PolicyEngine", "PluginManager", "PipelineResult"] diff --git a/bindings/python/python/cpex/_lib.pyi b/bindings/python/python/cpex/_lib.pyi index 987fe0eb..4241ed86 100644 --- a/bindings/python/python/cpex/_lib.pyi +++ b/bindings/python/python/cpex/_lib.pyi @@ -5,38 +5,58 @@ # # Type stubs for the cpex._lib native extension. # -# Uncertain awaitable return types are omitted rather than guessed (#5). -# `PluginManager` methods return coroutines; annotated as `Any` since -# the exact coroutine/awaitable type from PyO3 is not stable in stubs. +# Async methods return PyO3 awaitables. Their resolved type is given in the +# `Awaitable[...]` annotation. Underscore-prefixed TypedDicts are +# stub-only: they describe the dicts returned at runtime and cannot be +# imported. -from typing import Any, Optional +from datetime import datetime +from typing import Any, Awaitable, Optional, TypedDict + +class _RefreshFailure(TypedDict): + secret: str + provider: str + kind: str + message: str + +class _RefreshReport(TypedDict): + updated: list[str] + unchanged: int + failed: list[_RefreshFailure] class PipelineResult: @property def continue_processing(self) -> bool: ... @property - def modified_payload(self) -> Optional[dict]: ... + def modified_payload(self) -> Optional[dict[str, Any]]: ... @property def payload_modified(self) -> bool: ... @property - def modified_extensions(self) -> Optional[dict]: ... + def modified_extensions(self) -> Optional[dict[str, Any]]: ... @property - def violation(self) -> Optional[dict]: ... + def violation(self) -> Optional[dict[str, Any]]: ... @property - def errors(self) -> list[dict]: ... + def errors(self) -> list[dict[str, Any]]: ... @property - def metadata(self) -> Optional[dict]: ... + def metadata(self) -> Optional[dict[str, Any]]: ... @property - def context_table(self) -> dict: ... + def context_table(self) -> dict[str, Any]: ... -class PluginManager: - def __new__(cls, config_path: str) -> "PluginManager": ... - def initialize(self) -> Any: ... - def shutdown(self) -> Any: ... +class PolicyEngine: + def __new__( + cls, + config_path: str, + *, + allow_private_destinations: bool = False, + ) -> "PolicyEngine": ... + def initialize(self) -> Awaitable[None]: ... + def shutdown(self) -> Awaitable[None]: ... def invoke_hook( self, hook_name: str, - payload: dict, - extensions: Optional[dict] = None, - context_table: Optional[dict] = None, - ) -> Any: ... + payload: dict[str, Any], + extensions: Optional[dict[str, Any]] = None, + context_table: Optional[dict[str, Any]] = None, + ) -> Awaitable[PipelineResult]: ... + def refresh_secrets(self) -> Awaitable[_RefreshReport]: ... + def secrets_last_success(self, provider: str) -> Optional[datetime]: ... diff --git a/bindings/python/python/cpex/py.typed b/bindings/python/python/cpex/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/src/builtins.rs b/bindings/python/src/builtins.rs deleted file mode 100644 index d840d0ee..00000000 --- a/bindings/python/src/builtins.rs +++ /dev/null @@ -1,21 +0,0 @@ -// Location: ./bindings/python/src/builtins.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Ted Habeck -// -// Register the bundled APL plugin factories and the APL config visitor on a -// `PluginManager`. Must be called on the **same `Arc`** that will later be -// passed to `load_config_yaml` so the APL visitor's `Weak` -// upgrades correctly during load. -// -// Delegates to `cpex_builtins::install_builtins`, which mirrors the factory -// set shipped by cpex-ffi. Any new builtin added to cpex-builtins is -// automatically included here via the shared feature flags. - -use std::sync::Arc; - -use cpex_core::manager::PluginManager; - -pub fn register_builtin_factories(manager: &Arc) { - cpex_builtins::install_builtins(manager); -} diff --git a/bindings/python/src/conversions.rs b/bindings/python/src/conversions.rs index a3ff9bdd..d4524790 100644 --- a/bindings/python/src/conversions.rs +++ b/bindings/python/src/conversions.rs @@ -6,13 +6,14 @@ // PyObject ↔ serde_json::Value traversal, payload resolution, and // modified-payload serialization. // -// Never calls Python's `json` module from Rust — all conversion is direct +// Never calls Python's `json` module from Rust; all conversion is direct // PyObject inspection / construction (#2). -use cpex_core::cmf::MessagePayload; -use cpex_core::context::PluginContextTable; -use cpex_core::extensions::Extensions; -use cpex_core::hooks::payload::PluginPayload; +use praxis_policy_core::cmf::MessagePayload; +use praxis_policy_core::context::PluginContextTable; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::hooks::payload::PluginPayload; +use praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString}; @@ -20,14 +21,14 @@ use serde_json::{Map, Value}; /// Wraps any serde_json::Value for hooks that are not `cmf.*`. /// -/// Defined locally because `cpex-core` exports the macro but not the struct +/// Defined locally because `praxis-policy-core` exports the macro but not the struct /// itself (the FFI crate defines its own copy too). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GenericPayload { pub value: Value, } -cpex_core::impl_plugin_payload!(GenericPayload); +praxis_policy_core::impl_plugin_payload!(GenericPayload); /// Convert a Python object to a `serde_json::Value`. /// @@ -133,16 +134,49 @@ pub fn json_value_to_pyobj<'py>(py: Python<'py>, v: &Value) -> PyResult PyResult<()> { + match cpex::host::removed_hook_replacement(hook_name) { + Some(replacement) => Err(PyValueError::new_err(format!( + "cpex: hook '{hook_name}' was removed; use '{replacement}'" + ))), + None => Ok(()), + } +} + /// Build the correct `Box` for a hook. /// /// `cmf.*` hooks → `MessagePayload` (serde-constructed from the value). +/// `identity.resolve` → `IdentityPayload`. Tokens travel in `headers`; +/// the payload's raw token field is never deserialized. Only the input +/// fields (`source`, `source_header`, `headers`, `client_host`, +/// `client_port`) may be set: a dict that pre-fills a resolver output +/// field (`subject`, `client`, `caller_workload`, `delegation`, +/// `raw_credentials`, `resolved_at`, `raw_claims`) raises `ValueError`, +/// so a caller cannot forge the resolved identity. /// All other hook names → `GenericPayload { value }`. /// -/// A `from_value` failure on a CMF payload raises `ValueError` rather than -/// silently falling through to GenericPayload — the caller sent a cmf hook -/// with a dict that doesn't match the MessagePayload schema. +/// A `from_value` failure on a typed payload raises `ValueError` rather +/// than silently falling through to GenericPayload; the caller sent a dict +/// that doesn't match the hook's payload schema. pub fn resolve_payload(hook_name: &str, value: Value) -> PyResult> { - if hook_name.starts_with("cmf.") { + if hook_name == HOOK_IDENTITY_RESOLVE { + let idp: IdentityPayload = serde_json::from_value(value).map_err(|e| { + PyValueError::new_err(format!( + "cpex: payload for '{hook_name}' is not a valid IdentityPayload: {e}" + )) + })?; + let resolved = cpex::host::resolved_identity_fields(&idp); + if !resolved.is_empty() { + return Err(PyValueError::new_err(format!( + "cpex: payload for '{hook_name}' must not set resolver output fields: {}", + resolved.join(", ") + ))); + } + Ok(Box::new(idp)) + } else if hook_name.starts_with("cmf.") { let msg: MessagePayload = serde_json::from_value(value).map_err(|e| { PyValueError::new_err(format!( "cpex: payload for '{hook_name}' is not a valid MessagePayload: {e}" @@ -161,11 +195,21 @@ pub fn resolve_payload(hook_name: &str, value: Value) -> PyResult Option { if let Some(mp) = payload.as_any().downcast_ref::() { return serde_json::to_value(mp).ok(); } + if let Some(idp) = payload.as_any().downcast_ref::() { + return cpex::host::identity_result_value(idp).ok(); + } if let Some(gp) = payload.as_any().downcast_ref::() { return serde_json::to_value(&gp.value).ok(); } diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index 36fea6e0..061b491b 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -5,19 +5,20 @@ // // Maps Rust PluginError variants to Python exception types. -use cpex_core::error::PluginError; -use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError}; +use praxis_policy_core::error::PluginError; use pyo3::PyErr; +use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError}; /// Convert a `Box` into the appropriate Python exception. /// /// Mapping: -/// Config / UnknownHook → `ValueError` (config/conversion failures) -/// Timeout → `TimeoutError` -/// Execution / other → `RuntimeError` +/// Config / UnknownHook: `ValueError` (config/conversion failures, +/// including an unknown plugin kind or hook name) +/// Timeout: `TimeoutError` +/// Execution / Violation: `RuntimeError` /// /// Note: `PluginError::Violation` is unreachable on the `invoke_by_name` -/// path — denials surface as `PipelineResult { continue_processing: false, +/// path. Denials surface as `PipelineResult { continue_processing: false, /// violation: Some(...) }`, never as an `Err`. Kept here as a defensive /// catch-all that maps to `RuntimeError`. #[allow(clippy::boxed_local)] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 4b888179..a9ea8f67 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Ted Habeck // -// cpex-python — PyO3 native extension module `cpex._lib`. +// cpex-python: PyO3 native extension module `cpex._lib`. // // Module registration and tokio runtime initialization. // The runtime is initialized once with a multi-thread builder that honours @@ -12,13 +12,13 @@ use pyo3::prelude::*; -mod builtins; mod conversions; mod error; mod manager; mod result; +mod secrets; -use manager::PyPluginManager; +use manager::PyPolicyEngine; use result::PyPipelineResult; /// Name of the env var operators set to bound worker threads. @@ -53,7 +53,7 @@ fn _lib(m: &Bound<'_, PyModule>) -> PyResult<()> { // Initialize the pyo3-async-runtimes tokio runtime with a multi-thread // builder so async methods are dispatched onto a real thread pool rather // than a single-threaded executor. This must run before any `future_into_py` - // call — doing it here at module import time is the correct hook. + // call; doing it here at module import time is the correct hook. // // This is a separate runtime from cpex-ffi's SHARED_RUNTIME; the // shared-budget philosophy is mirrored, not the runtime instance. @@ -64,7 +64,7 @@ fn _lib(m: &Bound<'_, PyModule>) -> PyResult<()> { } pyo3_async_runtimes::tokio::init(builder); - m.add_class::()?; + m.add_class::()?; m.add_class::()?; Ok(()) diff --git a/bindings/python/src/manager.rs b/bindings/python/src/manager.rs index 917b803a..9b90d40e 100644 --- a/bindings/python/src/manager.rs +++ b/bindings/python/src/manager.rs @@ -3,136 +3,353 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Ted Habeck // -// `PyPluginManager` — PyO3 wrapper around `cpex_core::PluginManager`. +// `PyPolicyEngine`: PyO3 wrapper around `praxis_policy_core::engine::PolicyEngine`, +// exposed to Python as `cpex.PolicyEngine` (and the `cpex.PluginManager` +// alias). // // Construction is synchronous; lifecycle methods (`initialize`, `shutdown`, -// `invoke_hook`) are returned as Python awaitables via `future_into_py`. +// `invoke_hook`, `refresh_secrets`) are returned as Python awaitables via +// `future_into_py`. // -// The design sketch in the plan: -// [GIL held] convert payload/extensions/context under GIL -// [GIL release] future_into_py with timeout + tokio::spawn panic isolation +// Call shape: +// [GIL held] convert payload/extensions/context under the GIL +// [GIL released] future_into_py with timeout + tokio::spawn panic isolation // [GIL re-acq.] pipeline_result_to_py // // BackgroundTasks are dropped (not awaited per call); fire-and-forget tasks -// run on the manager's TaskTracker and are drained by `shutdown()`. +// run on the engine's TaskTracker and are drained by `shutdown()`. +// +// `initialize` and `shutdown` share one lifecycle lock (see `Lifecycle`), so +// a shutdown waits for an in-flight initialize instead of finding nothing to +// tear down while that initialize goes on to start plugins. use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use cpex_core::context::PluginContextTable; -use cpex_core::extensions::Extensions; -use cpex_core::manager::PluginManager; +use praxis_policy_core::context::PluginContextTable; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::PluginError; +use praxis_policy_core::extensions::Extensions; use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError}; use pyo3::prelude::*; -use pyo3_async_runtimes::tokio::future_into_py; +use pyo3::types::{PyDateTime, PyTzInfo}; +use pyo3_async_runtimes::tokio::{future_into_py, future_into_py_with_locals, get_current_locals}; +use tokio::task::JoinHandle; -use crate::builtins::register_builtin_factories; use crate::conversions::{ - context_table_from_value, extensions_from_value, pyobj_to_json_value, resolve_payload, + context_table_from_value, extensions_from_value, pyobj_to_json_value, reject_removed_hook, + resolve_payload, }; use crate::error::plugin_error_to_pyerr; use crate::result::pipeline_result_to_py; +use crate::secrets::Refresh; /// Wall-clock timeout for every async call through the PyO3 boundary. /// Mirrors `FFI_WALL_CLOCK_TIMEOUT` in cpex-ffi. const PY_WALL_CLOCK_TIMEOUT: Duration = Duration::from_secs(60); -#[pyclass(name = "PluginManager")] -pub struct PyPluginManager { - inner: Arc, +/// Why a lifecycle call failed, before it is mapped to a Python exception. +/// Kept free of `PyErr` so the lifecycle logic is testable without an +/// interpreter. +#[derive(Debug)] +pub(crate) enum LifecycleError { + /// `shutdown()` has started; the engine takes no more lifecycle calls. + ShutDown, + /// The wall-clock timeout elapsed. + Timeout, + /// The engine panicked; the message is the panic payload. + Panicked(String), + /// The engine reported an error. + Engine(Box), +} + +/// The engine plus the state that orders `initialize` and `shutdown`. +/// +/// Both hold `lock` for their whole run, and both run their engine call in +/// a spawned task that owns the lock guard. A timeout or a cancelled +/// awaitable therefore never drops PPE's `initialize` or `shutdown` part +/// way: the task finishes and only then lets the next lifecycle call in. +#[derive(Clone)] +pub(crate) struct Lifecycle { + engine: Arc, + lock: Arc>, + /// Set when `shutdown()` is called, or when an + /// initialize timeout begins its rollback. Every later call that would + /// drive the engine is refused. + shutdown_started: Arc, + /// Set once `PolicyEngine::shutdown` has completed. A later shutdown + /// is then a no-op; until then a timed-out shutdown can be retried. + shut_down: Arc, +} + +impl Lifecycle { + pub(crate) fn new(engine: Arc) -> Self { + Self { + engine, + lock: Arc::new(tokio::sync::Mutex::new(())), + shutdown_started: Arc::new(AtomicBool::new(false)), + shut_down: Arc::new(AtomicBool::new(false)), + } + } + + fn is_shutting_down(&self) -> bool { + self.shutdown_started.load(Ordering::Acquire) + } + + /// Initialize the engine, waiting for any lifecycle call in flight. + /// + /// On timeout the initialize keeps running in its task; once it ends, + /// the engine is shut down to roll back the plugins it started, and the + /// engine is terminal from the moment the timeout fires. + pub(crate) async fn initialize(&self, timeout: Duration) -> Result<(), LifecycleError> { + let guard = match tokio::time::timeout(timeout, Arc::clone(&self.lock).lock_owned()).await { + Ok(guard) => guard, + Err(_elapsed) => return Err(LifecycleError::Timeout), + }; + if self.is_shutting_down() { + return Err(LifecycleError::ShutDown); + } + let engine = Arc::clone(&self.engine); + let mut work = tokio::spawn(async move { + let result = engine.initialize().await; + (result, guard) + }); + match tokio::time::timeout(timeout, &mut work).await { + Ok(Ok((result, _guard))) => result.map_err(LifecycleError::Engine), + Ok(Err(join_err)) => Err(join_error(join_err)), + Err(_elapsed) => { + self.shutdown_started.store(true, Ordering::Release); + let engine = Arc::clone(&self.engine); + let shut_down = Arc::clone(&self.shut_down); + tokio::spawn(async move { + // Keep the lock through the rollback so a concurrent + // shutdown() waits for it rather than running beside it. + if let Ok((_result, _guard)) = work.await { + engine.shutdown().await; + shut_down.store(true, Ordering::Release); + } + }); + Err(LifecycleError::Timeout) + }, + } + } + + /// Start shutdown before returning an awaitable to Python. The task owns + /// the work, so dropping or cancelling the awaitable cannot undo it. + fn start_shutdown(&self, runtime: &tokio::runtime::Handle) -> JoinHandle<()> { + self.shutdown_started.store(true, Ordering::Release); + let lock = Arc::clone(&self.lock); + let engine = Arc::clone(&self.engine); + let shut_down = Arc::clone(&self.shut_down); + runtime.spawn(async move { + let _guard = lock.lock_owned().await; + if shut_down.load(Ordering::Acquire) { + return; + } + engine.shutdown().await; + shut_down.store(true, Ordering::Release); + }) + } + + /// Await a shutdown task, without cancelling it on timeout or cancellation. + async fn await_shutdown(work: JoinHandle<()>, timeout: Duration) -> Result<(), LifecycleError> { + match tokio::time::timeout(timeout, work).await { + Ok(Ok(())) => Ok(()), + Ok(Err(join_err)) => Err(join_error(join_err)), + Err(_elapsed) => Err(LifecycleError::Timeout), + } + } + + /// Shut the engine down, waiting for an in-flight initialize first. + /// A second call after a completed shutdown is a no-op. A timed-out + /// shutdown keeps running; a retry waits for it. + pub(crate) async fn shutdown(&self, timeout: Duration) -> Result<(), LifecycleError> { + let work = self.start_shutdown(&tokio::runtime::Handle::current()); + Self::await_shutdown(work, timeout).await + } + + /// Re-read every declared secret in an isolated task, so a panicking + /// provider surfaces as [`LifecycleError::Panicked`]. + pub(crate) async fn refresh_secrets( + &self, + timeout: Duration, + ) -> Result { + let engine = Arc::clone(&self.engine); + let work = tokio::spawn(async move { engine.refresh_secrets().await }); + match tokio::time::timeout(timeout, work).await { + Ok(Ok(report)) => Ok(report), + Ok(Err(join_err)) => Err(join_error(join_err)), + Err(_elapsed) => Err(LifecycleError::Timeout), + } + } +} + +/// Map a failed spawned task to a [`LifecycleError`]. +fn join_error(join_err: tokio::task::JoinError) -> LifecycleError { + if join_err.is_panic() { + LifecycleError::Panicked(panic_message(join_err)) + } else { + LifecycleError::Panicked("task was cancelled".to_owned()) + } +} + +/// The text of a panic payload, or `"unknown panic"`. +fn panic_message(join_err: tokio::task::JoinError) -> String { + let payload = join_err.into_panic(); + payload + .downcast_ref::<&str>() + .map(|s| (*s).to_owned()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_owned()) +} + +/// Map a [`LifecycleError`] from operation `op` to the documented Python +/// exception. +fn lifecycle_error_to_pyerr(err: LifecycleError, op: &str) -> PyErr { + match err { + LifecycleError::ShutDown => shut_down_error(op), + LifecycleError::Timeout => { + PyTimeoutError::new_err(format!("cpex: PolicyEngine::{op} timed out")) + }, + LifecycleError::Panicked(msg) => { + PyRuntimeError::new_err(format!("cpex: PolicyEngine::{op} panicked: {msg}")) + }, + LifecycleError::Engine(e) => plugin_error_to_pyerr(e), + } +} + +fn shut_down_error(op: &str) -> PyErr { + PyRuntimeError::new_err(format!( + "cpex: cannot call {op}() after shutdown(); create a new PolicyEngine" + )) +} + +#[pyclass(name = "PolicyEngine")] +pub struct PyPolicyEngine { + inner: Arc, + lifecycle: Lifecycle, +} + +impl PyPolicyEngine { + /// Raise `RuntimeError` when `shutdown()` already started. + fn ensure_live(&self, op: &str) -> PyResult<()> { + if self.lifecycle.is_shutting_down() { + return Err(shut_down_error(op)); + } + Ok(()) + } } #[pymethods] -impl PyPluginManager { - /// Create a new `PluginManager` from a YAML config file path. +impl PyPolicyEngine { + /// Create a new `PolicyEngine` from a YAML config file path. + /// + /// Synchronous construction; no Python event loop needed. /// - /// Synchronous construction — no Python event loop needed. + /// Steps (order is load-bearing for the APL visitor's `Weak` upgrade): + /// 1. `PolicyEngine::default()` into an `Arc` + /// 2. install builtins, the HTTP transport and the secret providers + /// on that same `Arc` + /// 3. read the file and `load_config_yaml` it /// - /// Steps (order is load-bearing for APL Weak upgrade): - /// 1. `PluginManager::default()` → `Arc` - /// 2. `register_builtin_factories(&arc)` — factories + APL visitor on - /// the same Arc that load_config_yaml will reference - /// 3. Read config file → `load_config_yaml(&arc, yaml)` — APL visitor - /// Weak upgrades here + /// `allow_private_destinations` (keyword only, default `False`) lets + /// builtins that perform HTTP (JWKS fetch, OAuth delegation, CIBA, + /// Vault) reach private, loopback and link-local addresses. Off, such + /// requests are refused and the builtin reports its egress-denied + /// code. No policy document can turn this on. /// /// Raises `ValueError` on missing file, IO error, YAML parse error, - /// or config validation error. + /// or config validation error (including an unknown plugin kind). #[new] - fn new(config_path: &str) -> PyResult { + #[pyo3(signature = (config_path, *, allow_private_destinations = false))] + fn new(config_path: &str, allow_private_destinations: bool) -> PyResult { let yaml = std::fs::read_to_string(config_path).map_err(|e| { PyValueError::new_err(format!( "cpex: cannot read config file '{config_path}': {e}" )) })?; - let manager = Arc::new(PluginManager::default()); - register_builtin_factories(&manager); - manager + let inner = Arc::new(PolicyEngine::default()); + cpex::host::install_host_defaults(&inner, allow_private_destinations); + inner .load_config_yaml(&yaml) .map_err(plugin_error_to_pyerr)?; - Ok(Self { inner: manager }) + Ok(Self { + lifecycle: Lifecycle::new(Arc::clone(&inner)), + inner, + }) } - /// Initialize all registered plugins. + /// Initialize all registered plugins and resolve declared secrets. /// /// Must be called before any `invoke_hook` call. - /// Returns an awaitable (coroutine). + /// Starts when called; await the returned future for its result. + /// + /// Raises `RuntimeError` once `shutdown()` has been called, including a + /// shutdown that raced with this call's scheduled work. + /// Raises `TimeoutError` past the wall-clock timeout; the engine then + /// shuts itself down once the stalled initialize ends, and is terminal. fn initialize<'py>(&self, py: Python<'py>) -> PyResult> { - let manager = Arc::clone(&self.inner); + self.ensure_live("initialize")?; + let lifecycle = self.lifecycle.clone(); future_into_py(py, async move { - let result = tokio::time::timeout(PY_WALL_CLOCK_TIMEOUT, async move { - manager.initialize().await.map_err(plugin_error_to_pyerr) - }) - .await; - - match result { - Ok(inner_result) => inner_result, - Err(_elapsed) => Err(PyTimeoutError::new_err( - "cpex: PluginManager::initialize timed out", - )), - } + lifecycle + .initialize(PY_WALL_CLOCK_TIMEOUT) + .await + .map_err(|e| lifecycle_error_to_pyerr(e, "initialize")) }) } /// Shut down all registered plugins and drain fire-and-forget tasks. /// - /// Returns an awaitable (coroutine). + /// Waits for an `initialize()` in flight, then shuts the engine down. + /// Terminal from the call itself: later + /// `initialize`, `invoke_hook` and `refresh_secrets` calls raise + /// `RuntimeError`. A second `shutdown()` after one completed is a + /// no-op. One that raised `TimeoutError` can be retried; the retry + /// waits for the first to finish. + /// Starts the shutdown task before returning its awaitable. Dropping or + /// cancelling the awaitable does not cancel the shutdown task. fn shutdown<'py>(&self, py: Python<'py>) -> PyResult> { - let manager = Arc::clone(&self.inner); - future_into_py(py, async move { - let result = tokio::time::timeout(PY_WALL_CLOCK_TIMEOUT, async move { - manager.shutdown().await; - Ok::<(), PyErr>(()) - }) - .await; - - match result { - Ok(inner_result) => inner_result, - Err(_elapsed) => Err(PyTimeoutError::new_err( - "cpex: PluginManager::shutdown timed out", - )), - } + // Resolve the Python event loop before starting teardown, so a call + // without an awaitable-capable loop cannot shut down the engine and + // then fail to return a way to observe its result. + let locals = get_current_locals(py)?; + let work = self + .lifecycle + .start_shutdown(pyo3_async_runtimes::tokio::get_runtime().handle()); + future_into_py_with_locals(py, locals, async move { + Lifecycle::await_shutdown(work, PY_WALL_CLOCK_TIMEOUT) + .await + .map_err(|e| lifecycle_error_to_pyerr(e, "shutdown")) }) } /// Invoke a hook by name. /// /// Args: - /// hook_name: str — e.g. `"cmf.tool_pre_invoke"` or any custom name. - /// payload: dict — converted via direct PyObject↔serde_json traversal - /// (no Python `json` module). - /// extensions: dict | None — optional cpex Extensions fields. - /// context_table: dict | None — optional PluginContextTable to thread + /// hook_name: str, e.g. `"cmf.tool_pre_invoke"` or any custom name. + /// payload: dict, converted via direct PyObject to serde_json + /// traversal (no Python `json` module). + /// extensions: dict | None, optional cpex Extensions fields. Under + /// the default `dispatch: policy`, `meta.entity_type` and + /// `meta.entity_name` select the route. + /// context_table: dict | None, optional PluginContextTable to thread /// through for stateful plugins. /// /// Returns an awaitable that resolves to `PipelineResult`. /// /// Raises: - /// `ValueError` — payload/extensions/context conversion failure, - /// or depth > 128. - /// `RuntimeError` — plugin execution error or panic at the boundary. - /// `TimeoutError` — wall-clock timeout exceeded. + /// `ValueError` a hook name removed in 0.3 (the message names + /// its replacement), an `identity.resolve` payload + /// that sets a resolver output field, + /// payload/extensions/context conversion failure, + /// or depth > 128. + /// `RuntimeError` plugin execution error, panic at the boundary, + /// or a call after `shutdown()`. + /// `TimeoutError` wall-clock timeout exceeded. #[pyo3(signature = (hook_name, payload, extensions=None, context_table=None))] fn invoke_hook<'py>( &self, @@ -142,6 +359,8 @@ impl PyPluginManager { extensions: Option<&Bound<'_, PyAny>>, context_table: Option<&Bound<'_, PyAny>>, ) -> PyResult> { + self.ensure_live("invoke_hook")?; + reject_removed_hook(hook_name)?; let payload_value = pyobj_to_json_value(py, payload, 0)?; let rust_payload = resolve_payload(hook_name, payload_value)?; @@ -157,13 +376,13 @@ impl PyPluginManager { }; let rust_context: Option = context_table_from_value(ctx_value)?; - let manager = Arc::clone(&self.inner); + let engine = Arc::clone(&self.inner); let hook_name = hook_name.to_string(); // GIL released: async execution with wall-clock timeout. // future_into_py catches panics via tokio's JoinHandle and converts them // to pyo3_async_runtimes.RustPanic (a PyException subclass). To keep the - // documented interface consistent — the docstring promises RuntimeError — + // documented interface consistent (the docstring promises RuntimeError) // we spawn invoke_by_name as an isolated tokio task and intercept the // JoinError::is_panic() case ourselves, converting it to RuntimeError. // This mirrors cpex-ffi's run_safely/catch_unwind pattern at the async @@ -171,7 +390,7 @@ impl PyPluginManager { future_into_py( py, invoke_with_timeout( - manager, + engine, hook_name, rust_payload, rust_extensions, @@ -180,6 +399,62 @@ impl PyPluginManager { ), ) } + + /// Re-read every declared secret (renewing a Vault token when due). + /// + /// Returns an awaitable resolving to a dict: + /// + /// {"updated": [str], "unchanged": int, + /// "failed": [{"secret": str, "provider": str, "kind": str, + /// "message": str}]} + /// + /// `kind` is one of `config`, `reference`, `not_found`, `malformed`, + /// `backend` or `other`. A failed re-read is listed, not raised; the + /// value keeps its last-good bytes. An engine with no `secrets:` block, + /// or not yet initialized, returns an empty report. + /// + /// Raises `RuntimeError` after `shutdown()` or when a secret provider + /// panics, and `TimeoutError` past the wall-clock timeout. + fn refresh_secrets<'py>(&self, py: Python<'py>) -> PyResult> { + self.ensure_live("refresh_secrets")?; + let lifecycle = self.lifecycle.clone(); + future_into_py(py, async move { + let report = lifecycle + .refresh_secrets(PY_WALL_CLOCK_TIMEOUT) + .await + .map_err(|e| lifecycle_error_to_pyerr(e, "refresh_secrets"))?; + // Log only the names and the failure class. The full message + // stays in the report returned to Python. + for failure in &report.failed { + tracing::warn!( + secret = %failure.secret, + provider = %failure.provider, + kind = cpex::host::secret_error_kind(&failure.source), + "cpex-python: secret refresh failed" + ); + } + Ok(Refresh::from(report)) + }) + } + + /// When `provider` last re-read all of its values without a failure, + /// as a timezone-aware UTC `datetime`, or `None`. + /// + /// `None` means the provider's most recent refresh failed, the name is + /// not a declared provider, or secrets are not resolved yet. + /// Initialization counts as the first success. Synchronous. + fn secrets_last_success<'py>( + &self, + py: Python<'py>, + provider: &str, + ) -> PyResult>> { + let Some(at) = self.inner.secrets_last_success(provider) else { + return Ok(None); + }; + let utc = PyTzInfo::utc(py)?.to_owned(); + let seconds = at.timestamp_millis() as f64 / 1000.0; + PyDateTime::from_timestamp(py, seconds, Some(&utc)).map(Some) + } } /// Drives a single `invoke_by_name` call with an isolated tokio task (panic @@ -188,9 +463,9 @@ impl PyPluginManager { /// Extracted from `invoke_hook` so the test module can pass a short timeout /// without touching `PY_WALL_CLOCK_TIMEOUT`. pub(crate) async fn invoke_with_timeout( - manager: Arc, + manager: Arc, hook_name: String, - rust_payload: Box, + rust_payload: Box, rust_extensions: Extensions, rust_context: Option, timeout: Duration, @@ -209,17 +484,10 @@ pub(crate) async fn invoke_with_timeout( .await { Ok((pipeline_result, _bg_tasks)) => pipeline_result_to_py(pipeline_result), - Err(join_err) if join_err.is_panic() => { - let payload = join_err.into_panic(); - let msg = payload - .downcast_ref::<&str>() - .copied() - .or_else(|| payload.downcast_ref::().map(String::as_str)) - .unwrap_or("unknown panic"); - Err(PyRuntimeError::new_err(format!( - "cpex: plugin panicked: {msg}" - ))) - }, + Err(join_err) if join_err.is_panic() => Err(PyRuntimeError::new_err(format!( + "cpex: plugin panicked: {}", + panic_message(join_err) + ))), Err(_cancelled) => Err(PyRuntimeError::new_err("cpex: plugin task was cancelled")), } }) @@ -247,24 +515,24 @@ mod tests { use std::sync::Arc; use async_trait::async_trait; - use cpex_core::context::PluginContext; - use cpex_core::extensions::Extensions; - use cpex_core::hooks::payload::PluginPayload; - use cpex_core::hooks::trait_def::HookTypeDef; - use cpex_core::hooks::PluginResult; - use cpex_core::manager::PluginManager; + use praxis_policy_core::context::PluginContext; + use praxis_policy_core::engine::PolicyEngine; + use praxis_policy_core::extensions::Extensions; + use praxis_policy_core::hooks::PluginResult; + use praxis_policy_core::hooks::payload::PluginPayload; + use praxis_policy_core::hooks::trait_def::HookTypeDef; use std::time::Duration; - use cpex_core::plugin::{Plugin, PluginConfig, PluginMode}; + use praxis_policy_core::plugin::{Plugin, PluginConfig, PluginMode}; - // A minimal payload type for test dispatch — must match the hook's + // A minimal payload type for test dispatch; must match the hook's // associated Payload type so the executor's typed-adapter downcast finds // the handler. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct TestPayload { value: String, } - cpex_core::impl_plugin_payload!(TestPayload); + praxis_policy_core::impl_plugin_payload!(TestPayload); struct PanicHook; impl HookTypeDef for PanicHook { @@ -291,7 +559,7 @@ mod tests { } } - impl cpex_core::hooks::HookHandler for PanickingPlugin { + impl praxis_policy_core::hooks::HookHandler for PanickingPlugin { async fn handle( &self, _payload: &TestPayload, @@ -302,7 +570,7 @@ mod tests { } } - /// A plugin that sleeps forever — exercises the wall-clock timeout path. + /// A plugin that sleeps forever; exercises the wall-clock timeout path. struct BlockingPlugin { cfg: PluginConfig, } @@ -314,7 +582,7 @@ mod tests { } } - impl cpex_core::hooks::HookHandler for BlockingPlugin { + impl praxis_policy_core::hooks::HookHandler for BlockingPlugin { async fn handle( &self, _payload: &TestPayload, @@ -326,8 +594,8 @@ mod tests { } } - fn build_blocking_manager() -> Arc { - let manager = Arc::new(PluginManager::default()); + fn build_blocking_manager() -> Arc { + let manager = Arc::new(PolicyEngine::default()); let cfg = PluginConfig { name: "blocker".into(), kind: "test".into(), @@ -342,8 +610,8 @@ mod tests { manager } - fn build_panicking_manager() -> Arc { - let manager = Arc::new(PluginManager::default()); + fn build_panicking_manager() -> Arc { + let manager = Arc::new(PolicyEngine::default()); let cfg = PluginConfig { name: "panicker".into(), kind: "test".into(), @@ -358,15 +626,13 @@ mod tests { manager } - /// The `tokio::spawn` in `invoke_hook` must catch a panicking plugin and - /// surface `JoinError::is_panic()` rather than aborting the process or - /// leaking the panic to the pyo3_async_runtimes dispatch task. - /// - /// This is the Rust-level regression test for the panic-isolation - /// guarantee; the Python-level guarantee is that `invoke_hook` raises - /// `RuntimeError` rather than `pyo3_async_runtimes.RustPanic`. + /// A panicking plugin must not abort the process or unwind into the + /// pyo3_async_runtimes dispatch task. PPE contains the panic in its + /// executor and reports it as a denial with code `plugin_panic`; the + /// `tokio::spawn` in `invoke_with_timeout` stays as the backstop for a + /// panic outside the executor. #[tokio::test] - async fn invoke_on_panicking_plugin_returns_join_error_is_panic() { + async fn invoke_on_panicking_plugin_is_contained_by_the_engine() { let manager = build_panicking_manager(); manager.initialize().await.expect("initialize"); @@ -376,35 +642,17 @@ mod tests { let hook_name = "test.panic_hook".to_string(); let manager2 = Arc::clone(&manager); - let join_result = tokio::spawn(async move { + let (result, _bg) = tokio::spawn(async move { manager2 .invoke_by_name(&hook_name, payload, Extensions::default(), None) .await }) - .await; - - assert!( - join_result.is_err(), - "spawned task should have failed due to panic" - ); - let join_err = join_result.unwrap_err(); - assert!( - join_err.is_panic(), - "JoinError should report is_panic()=true, not a cancellation" - ); + .await + .expect("the engine contains the panic; the task must not fail"); - // Verify the panic message is extractable — this is the same downcast - // logic used in invoke_hook to build the RuntimeError message. - let payload = join_err.into_panic(); - let msg = payload - .downcast_ref::<&str>() - .copied() - .or_else(|| payload.downcast_ref::().map(String::as_str)) - .unwrap_or("unknown panic"); - assert!( - msg.contains("simulated panic"), - "panic message should propagate, got: {msg}" - ); + assert!(!result.continue_processing, "a panic must deny"); + let violation = result.violation.expect("the panic is reported"); + assert_eq!(violation.code, "plugin_panic"); } /// The wall-clock timeout in `invoke_with_timeout` fires when a plugin @@ -412,7 +660,7 @@ mod tests { /// mechanism: `tokio::time::timeout` with a 1 ms deadline on a future that /// sleeps for 1 hour returns `Err(Elapsed)`. /// - /// We test at this level — below `invoke_with_timeout` — because + /// We test at this level (below `invoke_with_timeout`) because /// constructing a `PyErr` (e.g. `PyTimeoutError::new_err`) requires a live /// Python interpreter, which is unavailable in pure `cargo test` runs. /// The production mapping from `Elapsed` → `PyTimeoutError` is trivial and @@ -444,3 +692,218 @@ mod tests { ); } } + +// Lifecycle ordering, without a Python interpreter: the logic returns +// `LifecycleError`, which only the pymethods map to `PyErr`. +#[cfg(test)] +mod lifecycle_tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use async_trait::async_trait; + use praxis_policy_core::context::PluginContext; + use praxis_policy_core::engine::PolicyEngine; + use praxis_policy_core::error::PluginError; + use praxis_policy_core::extensions::Extensions; + use praxis_policy_core::hooks::PluginResult; + use praxis_policy_core::hooks::trait_def::HookTypeDef; + use praxis_policy_core::plugin::{Plugin, PluginConfig, PluginMode}; + + use super::{Lifecycle, LifecycleError, join_error}; + + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + struct TestPayload; + praxis_policy_core::impl_plugin_payload!(TestPayload); + + struct SlowHook; + impl HookTypeDef for SlowHook { + type Payload = TestPayload; + type Result = PluginResult; + const NAME: &'static str = "test.slow_hook"; + } + + /// Takes `init_delay` to initialize and counts its initializations and + /// shutdowns. + struct SlowPlugin { + cfg: PluginConfig, + init_delay: Duration, + inits: Arc, + shutdowns: Arc, + } + + #[async_trait] + impl Plugin for SlowPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + + async fn initialize(&self) -> Result<(), Box> { + tokio::time::sleep(self.init_delay).await; + self.inits.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + self.shutdowns.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + impl praxis_policy_core::hooks::HookHandler for SlowPlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + struct Probe { + lifecycle: Lifecycle, + engine: Arc, + inits: Arc, + shutdowns: Arc, + } + + fn probe(init_delay: Duration) -> Probe { + let engine = Arc::new(PolicyEngine::default()); + let cfg = PluginConfig { + name: "slow".into(), + kind: "test".into(), + hooks: vec![SlowHook::NAME.into()], + mode: PluginMode::Sequential, + ..Default::default() + }; + let inits = Arc::new(AtomicUsize::new(0)); + let shutdowns = Arc::new(AtomicUsize::new(0)); + let plugin = Arc::new(SlowPlugin { + cfg: cfg.clone(), + init_delay, + inits: Arc::clone(&inits), + shutdowns: Arc::clone(&shutdowns), + }); + engine + .register_handler::(plugin, cfg) + .expect("register"); + Probe { + lifecycle: Lifecycle::new(Arc::clone(&engine)), + engine, + inits, + shutdowns, + } + } + + const LONG: Duration = Duration::from_secs(10); + + /// Wait until a lifecycle call holds the lock, so the test's next call + /// is known to arrive while it is in flight. + async fn wait_for_lock_held(lifecycle: &Lifecycle) { + while lifecycle.lock.try_lock().is_ok() { + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + + /// A shutdown that arrives while initialize is in flight waits for it, + /// then tears the started plugins down. + #[tokio::test] + async fn shutdown_waits_for_an_in_flight_initialize() { + let p = probe(Duration::from_millis(100)); + let init = { + let lifecycle = p.lifecycle.clone(); + tokio::spawn(async move { lifecycle.initialize(LONG).await }) + }; + wait_for_lock_held(&p.lifecycle).await; + p.lifecycle.shutdown(LONG).await.expect("shutdown"); + + init.await.expect("join").expect("initialize"); + assert_eq!(p.inits.load(Ordering::SeqCst), 1); + assert_eq!(p.shutdowns.load(Ordering::SeqCst), 1); + assert!(!p.engine.is_initialized()); + + // A second shutdown is a no-op; initialize is refused. + p.lifecycle.shutdown(LONG).await.expect("second shutdown"); + assert_eq!(p.shutdowns.load(Ordering::SeqCst), 1); + assert!(matches!( + p.lifecycle.initialize(LONG).await, + Err(LifecycleError::ShutDown) + )); + } + + #[tokio::test] + async fn started_shutdown_survives_a_dropped_awaitable() { + let p = probe(Duration::ZERO); + p.lifecycle.initialize(LONG).await.expect("initialize"); + + let work = p + .lifecycle + .start_shutdown(&tokio::runtime::Handle::current()); + assert!(p.lifecycle.is_shutting_down(), "terminal at call time"); + drop(work); + + tokio::time::timeout(LONG, async { + while p.shutdowns.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("detached shutdown completes"); + assert!(!p.engine.is_initialized()); + p.lifecycle.shutdown(LONG).await.expect("retry is a no-op"); + assert_eq!(p.shutdowns.load(Ordering::SeqCst), 1); + } + + /// An initialize that times out is rolled back once it ends, and the + /// engine is terminal. + #[tokio::test] + async fn initialize_timeout_rolls_back() { + let p = probe(Duration::from_millis(100)); + assert!(matches!( + p.lifecycle.initialize(Duration::from_millis(10)).await, + Err(LifecycleError::Timeout) + )); + assert!(p.lifecycle.is_shutting_down()); + + // shutdown() waits for the rollback and finds nothing left to do. + p.lifecycle.shutdown(LONG).await.expect("shutdown"); + assert_eq!(p.inits.load(Ordering::SeqCst), 1); + assert_eq!(p.shutdowns.load(Ordering::SeqCst), 1); + assert!(!p.engine.is_initialized()); + } + + /// A shutdown that times out waiting can be retried, and the retry + /// runs the shutdown exactly once. + #[tokio::test] + async fn timed_out_shutdown_can_be_retried() { + let p = probe(Duration::from_millis(100)); + let init = { + let lifecycle = p.lifecycle.clone(); + tokio::spawn(async move { lifecycle.initialize(LONG).await }) + }; + wait_for_lock_held(&p.lifecycle).await; + assert!(matches!( + p.lifecycle.shutdown(Duration::from_millis(10)).await, + Err(LifecycleError::Timeout) + )); + + p.lifecycle.shutdown(LONG).await.expect("retried shutdown"); + init.await.expect("join").expect("initialize"); + assert_eq!(p.shutdowns.load(Ordering::SeqCst), 1); + assert!(!p.engine.is_initialized()); + } + + /// A panic in a spawned lifecycle task maps to `Panicked` with the + /// payload text, the path refresh_secrets uses for a provider panic. + #[tokio::test] + async fn a_task_panic_maps_to_panicked() { + let join_err = tokio::spawn(async { panic!("provider exploded") }) + .await + .expect_err("the task panics"); + match join_error(join_err) { + LifecycleError::Panicked(msg) => assert_eq!(msg, "provider exploded"), + other => panic!("expected Panicked, got {other:?}"), + } + } +} diff --git a/bindings/python/src/result.rs b/bindings/python/src/result.rs index 8d4cdc73..ac159e6e 100644 --- a/bindings/python/src/result.rs +++ b/bindings/python/src/result.rs @@ -3,20 +3,21 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Ted Habeck // -// `PyPipelineResult` — read-only Python view of `cpex_core::PipelineResult`. +// `PyPipelineResult`: read-only Python view of +// `praxis_policy_core::executor::PipelineResult`. // // Mirrors the field set of `PipelineResult` exactly. All fields are read-only // getters; no setters are exposed. // // If the modified_payload could not be serialised back to a Python dict the // caller appends a synthetic `PluginErrorRecord` to `errors` (#8); -// `modified_payload` is exposed as `None` in that case — mirrors the pattern +// `modified_payload` is exposed as `None` in that case; mirrors the pattern // at `crates/cpex-ffi/src/lib.rs:877`. use std::collections::HashMap; -use cpex_core::error::PluginErrorRecord; -use cpex_core::executor::PipelineResult; +use praxis_policy_core::error::PluginErrorRecord; +use praxis_policy_core::executor::PipelineResult; use pyo3::prelude::*; use pyo3::types::PyDict; use serde_json::Value; @@ -61,7 +62,7 @@ impl PyPipelineResult { /// /// `modified_payload` is set on every allowed pipeline, carrying the /// final payload whether or not a plugin touched it, so it never - /// answered "did anything change?". Read this instead — comparing + /// answered "did anything change?". Read this instead; comparing /// payload contents cannot see mutations to non-text content parts. #[getter] fn payload_modified(&self) -> bool { @@ -148,7 +149,7 @@ impl PyPipelineResult { /// /// If `modified_payload` is present but cannot be serialised, a synthetic /// `PluginErrorRecord` is appended to `errors` and `modified_payload` is -/// exposed as `None` — mirrors cpex-ffi's behaviour at lib.rs:877 (#8). +/// exposed as `None`; mirrors cpex-ffi's behaviour at lib.rs:877 (#8). pub fn pipeline_result_to_py(mut result: PipelineResult) -> PyResult { // Serialise modified_payload; on failure emit a synthetic error record. let modified_payload_value: Option = match result.modified_payload.take() { diff --git a/bindings/python/src/secrets.rs b/bindings/python/src/secrets.rs new file mode 100644 index 00000000..c8db2a8a --- /dev/null +++ b/bindings/python/src/secrets.rs @@ -0,0 +1,52 @@ +// Location: ./bindings/python/src/secrets.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Secret refresh report as it crosses into Python. +// +// PPE resolves every declared secret once, at `initialize`, and never +// re-reads on its own: the host decides when. The report shape and the +// failure kind strings come from `cpex::host::secret_error_kind`, which the +// C FFI (`cpex_refresh_secrets`) uses too, so Go and Python hosts read the +// same fields. + +use cpex::host::RefreshSummary; +use praxis_policy_core::secrets::RefreshReport; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; + +/// What a refresh changed and what failed, detached from the engine so +/// it can cross the async boundary before the GIL is taken. +pub struct Refresh(RefreshSummary); + +impl From for Refresh { + fn from(report: RefreshReport) -> Self { + Self(RefreshSummary::from(report)) + } +} + +impl<'py> IntoPyObject<'py> for Refresh { + type Target = PyDict; + type Output = Bound<'py, PyDict>; + type Error = PyErr; + + /// `{"updated": [str], "unchanged": int, "failed": [{"secret", + /// "provider", "kind", "message"}]}`. + fn into_pyobject(self, py: Python<'py>) -> PyResult { + let failed = PyList::empty(py); + for f in self.0.failed { + let entry = PyDict::new(py); + entry.set_item("secret", f.secret)?; + entry.set_item("provider", f.provider)?; + entry.set_item("kind", f.kind)?; + entry.set_item("message", f.message)?; + failed.append(entry)?; + } + let out = PyDict::new(py); + out.set_item("updated", self.0.updated)?; + out.set_item("unchanged", self.0.unchanged)?; + out.set_item("failed", failed)?; + Ok(out) + } +} diff --git a/bindings/python/tests/_support.py b/bindings/python/tests/_support.py new file mode 100644 index 00000000..ba6487e5 --- /dev/null +++ b/bindings/python/tests/_support.py @@ -0,0 +1,62 @@ +# Location: ./bindings/python/tests/_support.py +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Payload and extension builders shared by the binding tests. + +from pathlib import Path +from typing import Any + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +PII_DENY_CONFIG = str(FIXTURES_DIR / "pii_deny.yaml") +PII_REDACT_CONFIG = str(FIXTURES_DIR / "pii_redact.yaml") +PASSTHROUGH_CONFIG = str(FIXTURES_DIR / "passthrough.yaml") + +# The wire-compat list pinned by the C FFI tests. One file for both +# surfaces, so a new PPE builtin is one edit. +WIRE_COMPAT_JSON = ( + Path(__file__).resolve().parents[3] + / "crates" + / "cpex-ffi" + / "tests" + / "fixtures" + / "wire_compat.json" +) + + +def tool_call(name: str, arguments: dict[str, Any], call_id: str = "tc_001") -> dict: + """A CMF payload carrying one assistant tool call.""" + return { + "message": { + "role": "assistant", + "content": [ + { + "content_type": "tool_call", + "content": { + "tool_call_id": call_id, + "name": name, + "arguments": arguments, + }, + } + ], + } + } + + +def text_message(text: str) -> dict: + """A CMF payload carrying one user text part.""" + return { + "message": { + "role": "user", + "content": [{"content_type": "text", "text": text}], + } + } + + +def tool_route(name: str) -> dict: + """Extensions that select the `routes:` entry for tool `name`. + + Under the default `dispatch: policy`, the engine picks the route from + `meta.entity_type` and `meta.entity_name`. + """ + return {"meta": {"entity_type": "tool", "entity_name": name}} diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py index e1291c7d..aebedb92 100644 --- a/bindings/python/tests/conftest.py +++ b/bindings/python/tests/conftest.py @@ -5,30 +5,34 @@ # # Pytest fixtures for cpex-python binding tests. # -# No module-level os.environ mutation — env changes are scoped to the -# test function that needs them (mirrors tests/unit/cpex/conftest.py style). - -import os -from pathlib import Path +# No module-level os.environ mutation: env changes are scoped to the +# test function that needs them. import pytest -from cpex import PluginManager +from cpex import PolicyEngine -FIXTURES_DIR = Path(__file__).parent / "fixtures" -PII_DENY_CONFIG = str(FIXTURES_DIR / "pii_deny.yaml") -PII_REDACT_CONFIG = str(FIXTURES_DIR / "pii_redact.yaml") +from _support import PASSTHROUGH_CONFIG, PII_DENY_CONFIG, PII_REDACT_CONFIG @pytest.fixture -async def manager(): - """Create, initialize, and yield a PluginManager backed by pii_deny.yaml. +async def engine(): + """Create, initialize, and yield a PolicyEngine backed by pii_deny.yaml. - Shuts down after the test so fire-and-forget tasks are drained (KD4). + Shuts down after the test so fire-and-forget tasks are drained. """ - mgr = PluginManager(PII_DENY_CONFIG) - await mgr.initialize() - yield mgr - await mgr.shutdown() + eng = PolicyEngine(PII_DENY_CONFIG) + await eng.initialize() + yield eng + await eng.shutdown() + + +@pytest.fixture +async def passthrough_engine(): + """Create, initialize, and yield a PolicyEngine with nothing to enforce.""" + eng = PolicyEngine(PASSTHROUGH_CONFIG) + await eng.initialize() + yield eng + await eng.shutdown() @pytest.fixture diff --git a/bindings/python/tests/fixtures/passthrough.yaml b/bindings/python/tests/fixtures/passthrough.yaml new file mode 100644 index 00000000..c425fd6d --- /dev/null +++ b/bindings/python/tests/fixtures/passthrough.yaml @@ -0,0 +1,16 @@ +# Location: ./bindings/python/tests/fixtures/passthrough.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Test fixture: an engine with nothing to enforce. +# +# Used by test_conversions.py and test_errors.py, which exercise the +# Python to Rust value conversion and need every call to come back +# allowed with its payload intact. `dispatch: hooks` fires plugins at the +# hooks they declare instead of by route, so a request needs no route +# meta; with no plugins declared, nothing fires. + +engine_settings: + dispatch: hooks + +plugins: [] diff --git a/bindings/python/tests/fixtures/pii_deny.yaml b/bindings/python/tests/fixtures/pii_deny.yaml index 66e2ea9a..61cdd5a8 100644 --- a/bindings/python/tests/fixtures/pii_deny.yaml +++ b/bindings/python/tests/fixtures/pii_deny.yaml @@ -2,31 +2,26 @@ # Copyright 2025 # SPDX-License-Identifier: Apache-2.0 # -# Test fixture — validator/pii-scan in deny mode + audit/logger fire-and-forget. +# Test fixture: deny a tool call whose `ssn` argument holds a US SSN. # -# Used by test_manager.py for AE2 (KD10): a cmf.tool_pre_invoke invoke with a -# payload containing an SSN → continue_processing=False, violation present. -# The audit/logger runs as fire-and-forget and is asserted post-shutdown (KD4). +# Used by test_manager.py and test_result.py: a cmf.tool_pre_invoke on +# tool `lookup_person` with an SSN in the arguments gives +# continue_processing=False and a violation with code `pii.ssn_detected`. +# +# The check is a CEL decision on the route. A CEL `expr` states when the +# call is allowed; `on_deny` names the violation reported otherwise. +# Routes are selected by `extensions.meta.entity_type` / `entity_name`, +# so a call without that meta (or for another tool) is allowed. -plugins: - - name: pii_deny - kind: validator/pii-scan - version: 1.0.0 - hooks: - - cmf.tool_pre_invoke - mode: sequential - priority: 10 - on_error: fail - config: - mode: deny - detect: - - kind: ssn +global: + pdp: + - kind: cel - - name: audit_faf - kind: audit/logger - version: 1.0.0 - hooks: - - cmf.tool_pre_invoke - mode: fire_and_forget - priority: 100 - on_error: ignore +routes: + - tool: lookup_person + authorization: + pre_invocation: + - cel: + expr: "!(has(args.ssn) && args.ssn.matches('^[0-9]{3}-[0-9]{2}-[0-9]{4}$'))" + on_deny: + - "deny('ssn found in tool arguments', 'pii.ssn_detected')" diff --git a/bindings/python/tests/fixtures/pii_redact.yaml b/bindings/python/tests/fixtures/pii_redact.yaml index 496996e5..41e66238 100644 --- a/bindings/python/tests/fixtures/pii_redact.yaml +++ b/bindings/python/tests/fixtures/pii_redact.yaml @@ -2,22 +2,20 @@ # Copyright 2025 # SPDX-License-Identifier: Apache-2.0 # -# Test fixture — validator/pii-scan in redact mode. +# Test fixture: redact the `ssn` argument of a tool call. # -# Used by test_manager.py to cover the modified_payload round-trip: -# a cmf.tool_pre_invoke invoke with a payload containing an SSN → -# continue_processing=True, modified_payload present with SSN replaced by [PII]. +# Used by test_manager.py to cover the modified_payload round-trip: a +# cmf.tool_pre_invoke on tool `lookup_person` gives continue_processing=True +# and a modified_payload whose `ssn` argument reads `[REDACTED]`. +# +# The `args:` field pipeline rewrites the argument before the call goes +# out. The `allow` step is there because an `authorization:` block must +# name at least one step. -plugins: - - name: pii_redact - kind: validator/pii-scan - version: 1.0.0 - hooks: - - cmf.tool_pre_invoke - mode: sequential - priority: 10 - on_error: fail - config: - mode: redact - detect: - - kind: ssn +routes: + - tool: lookup_person + args: + ssn: "str | redact" + authorization: + pre_invocation: + - "allow" diff --git a/bindings/python/tests/smoke_wheel.py b/bindings/python/tests/smoke_wheel.py new file mode 100644 index 00000000..6631433c --- /dev/null +++ b/bindings/python/tests/smoke_wheel.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# Location: ./bindings/python/tests/smoke_wheel.py +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Wheel smoke test: build the cpex wheel, install it into a fresh venv, +# import cpex there and run a policy. +# +# This checks what the in-tree `maturin develop` tests cannot: that the +# built wheel is self-contained (native module, Python package, stubs) +# and works outside the source tree. +# +# Usage (from anywhere): +# python bindings/python/tests/smoke_wheel.py # build, then test +# python bindings/python/tests/smoke_wheel.py --wheel W.whl # test a built wheel +# +# Building uses `maturin` from PATH, or installs it into a throwaway build +# venv when it is missing. Only the standard library is needed to run +# this script. Exits non-zero on any failure. Not collected by pytest. + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + +BINDINGS_DIR = Path(__file__).resolve().parents[1] + +POLICY = """\ +global: + pdp: + - kind: cel + +routes: + - tool: lookup_person + authorization: + pre_invocation: + - cel: + expr: "!has(args.ssn)" + on_deny: + - "deny('ssn found in tool arguments', 'pii.ssn_detected')" +""" + +# Runs inside the fresh venv. Must not see the source tree. +CHECK = """\ +import asyncio, importlib.resources, sys +import cpex +from cpex import PipelineResult, PluginManager, PolicyEngine + +assert "site-packages" in cpex.__file__, f"imported from the source tree: {cpex.__file__}" +assert PluginManager is PolicyEngine + +# The stubs ship in the wheel: PEP 561 marker plus the native module's stub. +pkg = importlib.resources.files("cpex") +for stub in ("py.typed", "_lib.pyi"): + assert pkg.joinpath(stub).is_file(), f"wheel is missing cpex/{stub}" + +def call(args): + return {"message": {"role": "assistant", "content": [{"content_type": "tool_call", + "content": {"tool_call_id": "tc_1", "name": "lookup_person", "arguments": args}}]}} + +ROUTE = {"meta": {"entity_type": "tool", "entity_name": "lookup_person"}} + +async def main(path): + engine = PolicyEngine(path) + await engine.initialize() + allowed = await engine.invoke_hook("cmf.tool_pre_invoke", call({"name": "Alice"}), ROUTE) + denied = await engine.invoke_hook("cmf.tool_pre_invoke", call({"ssn": "123-45-6789"}), ROUTE) + report = await engine.refresh_secrets() + await engine.shutdown() + assert isinstance(allowed, PipelineResult) + assert allowed.continue_processing is True, allowed.violation + assert denied.continue_processing is False + assert denied.violation["code"] == "pii.ssn_detected", denied.violation + assert report == {"updated": [], "unchanged": 0, "failed": []}, report + print(f"cpex wheel smoke test passed ({cpex.__file__})") + +asyncio.run(main(sys.argv[1])) +""" + + +def run(cmd: list[str], **kwargs) -> None: + print("+", " ".join(str(c) for c in cmd), flush=True) + subprocess.run(cmd, check=True, **kwargs) + + +def venv_python(root: Path) -> Path: + scripts = "Scripts" if sys.platform == "win32" else "bin" + exe = "python.exe" if sys.platform == "win32" else "python" + return root / scripts / exe + + +def make_venv(root: Path) -> Path: + venv.EnvBuilder(with_pip=True, clear=True).create(root) + return venv_python(root) + + +def build_wheel(work: Path) -> Path: + out = work / "dist" + maturin = shutil.which("maturin") + if maturin is None: + build_py = make_venv(work / "build-venv") + run([str(build_py), "-m", "pip", "install", "--quiet", "maturin>=1,<2"]) + cmd = [str(build_py), "-m", "maturin"] + else: + cmd = [maturin] + run( + cmd + ["build", "--release", "--interpreter", sys.executable, "--out", str(out)], + cwd=BINDINGS_DIR, + ) + wheels = sorted(out.glob("cpex-*.whl")) + if len(wheels) != 1: + sys.exit(f"expected one wheel in {out}, found {wheels}") + return wheels[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build and smoke test the CPEX Python wheel") + parser.add_argument("--wheel", type=Path, help="test this wheel instead of building one") + parser.add_argument("--keep", action="store_true", help="keep the scratch directory") + args = parser.parse_args() + + work = Path(tempfile.mkdtemp(prefix="cpex-wheel-smoke-")) + try: + wheel = args.wheel.resolve() if args.wheel else build_wheel(work) + print(f"testing {wheel}", flush=True) + + py = make_venv(work / "smoke-venv") + run([str(py), "-m", "pip", "install", "--quiet", "--no-deps", str(wheel)]) + + policy = work / "policy.yaml" + policy.write_text(POLICY) + check = work / "check.py" + check.write_text(CHECK) + # Run from the scratch dir with -I so neither the cwd nor the source + # tree can shadow the installed package. + run([str(py), "-I", str(check), str(policy)], cwd=work) + finally: + if args.keep: + print(f"kept {work}") + else: + shutil.rmtree(work, ignore_errors=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bindings/python/tests/test_acceptance.py b/bindings/python/tests/test_acceptance.py new file mode 100644 index 00000000..9d0b8c00 --- /dev/null +++ b/bindings/python/tests/test_acceptance.py @@ -0,0 +1,399 @@ +# Location: ./bindings/python/tests/test_acceptance.py +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Acceptance flows through the native library, the way a Python host +# drives them: load a config, resolve identity from a bearer token, apply +# the subject to the tool call's extensions, and read the decision. +# +# The JWT issuer is a loopback JWKS server serving one HS256 key, so the +# tests need no crypto package and no network. Loopback is a private +# destination, so engines that fetch from it opt in with +# allow_private_destinations=True. +# +# Covered elsewhere and not repeated here: CIBA elicitation and the +# private-destination refusal for it (test_builtins.py), and the engine +# semantics behind each flow (PPE's own e2e suites). Delegation is not +# drivable from Python: inbound tokens are never serialized, so they +# cannot cross the binding into a later tool call. The Go acceptance +# suite covers it through the single-call identity path. + +import base64 +import hashlib +import hmac +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest +from cpex import PolicyEngine + +from _support import tool_call, tool_route + +ISSUER = "https://idp.acceptance.test" +AUDIENCE = "cpex-acceptance" +KID = "acceptance-key" +HS256_SECRET = b"acceptance-only-hs256-secret-0123456789" + + +# --------------------------------------------------------------------------- +# Loopback JWKS issuer +# --------------------------------------------------------------------------- + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def mint(sub: str, roles: list[str], *, exp_offset: int = 600, secret: bytes = HS256_SECRET) -> str: + """An HS256 JWT signed with the issuer key (or `secret`, to forge one).""" + header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT", "kid": KID}).encode()) + claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "sub": sub, + "iat": int(time.time()) - 5, + "exp": int(time.time()) + exp_offset, + "roles": roles, + } + body = _b64url(json.dumps(claims).encode()) + sig = hmac.new(secret, f"{header}.{body}".encode(), hashlib.sha256).digest() + return f"{header}.{body}.{_b64url(sig)}" + + +class _Jwks(BaseHTTPRequestHandler): + """Serves the issuer's key set and records each fetch.""" + + fetches: list = [] + + def do_GET(self): + type(self).fetches.append(self.path) + doc = { + "keys": [ + {"kty": "oct", "kid": KID, "alg": "HS256", "use": "sig", "k": _b64url(HS256_SECRET)} + ] + } + out = json.dumps(doc).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + def log_message(self, *args): + pass + + +@pytest.fixture +def jwks_url(): + """A loopback JWKS endpoint. Fetches are recorded in `_Jwks.fetches`.""" + _Jwks.fetches = [] + server = HTTPServer(("127.0.0.1", 0), _Jwks) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}/jwks" + server.shutdown() + server.server_close() + + +def _idp_plugin(jwks: str, *, perform_http: bool = True) -> str: + caps = " capabilities: [perform_http]\n" if perform_http else "" + return ( + "plugins:\n" + " - name: idp\n" + " kind: identity/jwt\n" + " hooks: [identity.resolve]\n" + f"{caps}" + " config:\n" + " claim_mapper: standard\n" + " trusted_issuers:\n" + f" - issuer: {ISSUER}\n" + f" audiences: [{AUDIENCE}]\n" + " algorithms: [HS256]\n" + " decoding_key:\n" + " kind: jwks_url\n" + f' url: "{jwks}"\n' + " insecure_http: true\n" + ) + + +def _write(tmp_path, text: str) -> str: + path = tmp_path / "policy.yaml" + path.write_text(text) + return str(path) + + +async def _engine(tmp_path, text: str, **kwargs) -> PolicyEngine: + eng = PolicyEngine(_write(tmp_path, text), **kwargs) + await eng.initialize() + return eng + + +async def _resolve(eng: PolicyEngine, token: str, tool: str): + """Run identity.resolve for a bearer token on `tool`'s route.""" + return await eng.invoke_hook( + "identity.resolve", + {"source": "bearer", "headers": {"authorization": f"Bearer {token}"}}, + tool_route(tool), + ) + + +async def _call(eng: PolicyEngine, token: str, tool: str, args: dict, session: str | None = None): + """Resolve the caller, then run the tool call with their subject applied. + + Returns the pre-invoke result, or the identity result when resolution + stops the request. + """ + ident = await _resolve(eng, token, tool) + if not ident.continue_processing: + return ident + ext = tool_route(tool) + ext["security"] = {"subject": ident.modified_payload["subject"]} + if session is not None: + ext["agent"] = {"session_id": session} + return await eng.invoke_hook("cmf.tool_pre_invoke", tool_call(tool, args), ext) + + +# --------------------------------------------------------------------------- +# Config load +# --------------------------------------------------------------------------- + + +def test_removed_global_policies_key_fails_naming_the_key(tmp_path): + """A 0.2.x `global.policies:` block is an unknown key, named in the error.""" + path = _write(tmp_path, "global:\n policies:\n - name: legacy\n") + with pytest.raises(ValueError, match="unknown key `policies`") as info: + PolicyEngine(path) + assert "groups" in str(info.value), "the error points at the replacement block" + + +def test_removed_global_apl_wrapper_fails(tmp_path): + """The 0.2.x `global.apl:` wrapper is gone; its keys moved up a level.""" + path = _write(tmp_path, "global:\n apl:\n pdp:\n - kind: cel\n") + with pytest.raises(ValueError, match="unknown key `apl`"): + PolicyEngine(path) + + +def test_legacy_hook_name_fails(tmp_path): + """A 0.2.x hook name is rejected at load.""" + path = _write( + tmp_path, + "engine_settings:\n dispatch: hooks\n" + "plugins:\n - name: idp\n kind: identity/jwt\n hooks: [identity_resolve]\n", + ) + with pytest.raises(ValueError, match="unknown hook"): + PolicyEngine(path) + + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + +IDENTITY_ROUTES = """ +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - "require(authenticated)" + - "require(role.hr)" +""" + + +@pytest.mark.asyncio +async def test_jwt_identity_gates_a_route_by_role(tmp_path, jwks_url): + """A valid token resolves a subject; the route allows hr and denies others.""" + eng = await _engine( + tmp_path, _idp_plugin(jwks_url) + IDENTITY_ROUTES, allow_private_destinations=True + ) + assert _Jwks.fetches == ["/jwks"], "initialize fetches the key set once" + + ident = await _resolve(eng, mint("alice", ["hr"]), "get_compensation") + hr = await _call(eng, mint("alice", ["hr"]), "get_compensation", {"employee_id": "e-1"}) + engineer = await _call(eng, mint("evan", ["engineer"]), "get_compensation", {"employee_id": "e-1"}) + await eng.shutdown() + + assert ident.continue_processing is True + subject = ident.modified_payload["subject"] + assert subject["id"] == "alice" + assert subject["roles"] == ["hr"] + assert "raw_token" not in ident.modified_payload + assert hr.continue_processing is True + assert engineer.continue_processing is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "token, code", + [ + (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"), + ], + ids=["expired", "forged", "malformed"], +) +async def test_jwt_identity_rejects_bad_tokens(tmp_path, jwks_url, token, code): + """Identity resolution stops a bad token with its auth code.""" + eng = await _engine( + tmp_path, _idp_plugin(jwks_url) + IDENTITY_ROUTES, allow_private_destinations=True + ) + result = await _resolve(eng, token, "get_compensation") + await eng.shutdown() + + assert result.continue_processing is False + assert result.violation["code"].startswith(code), result.violation + + +@pytest.mark.asyncio +async def test_jwks_on_loopback_is_refused_without_the_opt_in(tmp_path, jwks_url): + """By default the transport never reaches a loopback JWKS; tokens fail closed.""" + eng = await _engine(tmp_path, _idp_plugin(jwks_url) + IDENTITY_ROUTES) + result = await _resolve(eng, mint("alice", ["hr"]), "get_compensation") + await eng.shutdown() + + assert _Jwks.fetches == [] + assert result.continue_processing is False + assert result.violation["code"] == "auth.jwks_unavailable" + + +@pytest.mark.asyncio +async def test_jwks_url_without_perform_http_fails_to_start(tmp_path, jwks_url): + """A `jwks_url` issuer on a plugin without `perform_http` refuses to initialize.""" + eng = PolicyEngine( + _write(tmp_path, _idp_plugin(jwks_url, perform_http=False) + IDENTITY_ROUTES), + allow_private_destinations=True, + ) + with pytest.raises(RuntimeError, match="perform_http") as info: + await eng.initialize() + assert "idp" in str(info.value) + assert _Jwks.fetches == [] + + +# --------------------------------------------------------------------------- +# PDP decisions on the resolved subject +# --------------------------------------------------------------------------- + +CEDAR = """ +global: + pdp: + - kind: cedar-direct + policy_text: | + @id("hr-read-compensation") + permit(principal, action == Action::"read", resource) + when { principal.roles.contains("hr") }; + + @id("contractor-forbid") + forbid(principal, action, resource) + when { principal.roles.contains("contractor") }; + +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - cedar: + action: 'Action::"read"' + resource: + type: Compensation + id: e-1001 +""" + + +@pytest.mark.asyncio +async def test_cedar_decides_on_the_jwt_subject(tmp_path, jwks_url): + """Cedar permits hr, default-denies others, and names a forbid by policy id.""" + eng = await _engine(tmp_path, _idp_plugin(jwks_url) + CEDAR, allow_private_destinations=True) + args = {"employee_id": "e-1001"} + hr = await _call(eng, mint("alice", ["hr"]), "get_compensation", args) + engineer = await _call(eng, mint("evan", ["engineer"]), "get_compensation", args) + contractor = await _call(eng, mint("carl", ["hr", "contractor"]), "get_compensation", args) + await eng.shutdown() + + assert hr.continue_processing is True + assert engineer.continue_processing is False + assert engineer.violation["code"] == "cedar.default_deny" + assert contractor.continue_processing is False + assert "contractor-forbid" in contractor.violation["code"] + + +OPA = """ +global: + pdp: + - kind: opa + modules: + - | + package cpex.acceptance + default allow := false + allow if { + input.subject.id == "alice" + input.args.amount <= 100 + } + +routes: + - tool: transfer + authentication: [idp] + authorization: + pre_invocation: + - opa: + query: "data.cpex.acceptance.allow" +""" + + +@pytest.mark.asyncio +async def test_opa_decides_on_the_jwt_subject_and_args(tmp_path, jwks_url): + """Rego reads the resolved subject and the tool arguments together.""" + eng = await _engine(tmp_path, _idp_plugin(jwks_url) + OPA, allow_private_destinations=True) + alice = mint("alice", ["finance"]) + small = await _call(eng, alice, "transfer", {"amount": 50}) + large = await _call(eng, alice, "transfer", {"amount": 500}) + other = await _call(eng, mint("evan", ["finance"]), "transfer", {"amount": 50}) + await eng.shutdown() + + assert small.continue_processing is True + assert large.continue_processing is False + assert other.continue_processing is False + assert other.violation["code"] == "opa" + + +# --------------------------------------------------------------------------- +# Session labels +# --------------------------------------------------------------------------- + +SESSION = """ +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - "require(role.hr)" + - "taint(secret, session)" + + - tool: send_email + authentication: [idp] + authorization: + pre_invocation: + - "require(authenticated)" + - "security.labels contains \\"secret\\": deny('write-down blocked', 'session_tainted')" +""" + + +@pytest.mark.asyncio +async def test_session_label_blocks_write_down_in_the_same_session(tmp_path, jwks_url): + """Reading compensation taints the session; a later email there is denied.""" + eng = await _engine(tmp_path, _idp_plugin(jwks_url) + SESSION, allow_private_destinations=True) + alice = mint("alice", ["hr"]) + email = {"to": "bob@corp.example"} + before = await _call(eng, alice, "send_email", email, session="sess-1") + read = await _call(eng, alice, "get_compensation", {"employee_id": "e-1"}, session="sess-1") + after = await _call(eng, alice, "send_email", email, session="sess-1") + fresh = await _call(eng, alice, "send_email", email, session="sess-2") + other = await _call(eng, mint("evan", ["engineer"]), "send_email", email, session="sess-1") + await eng.shutdown() + + assert before.continue_processing is True + assert read.continue_processing is True + assert after.continue_processing is False + assert after.violation["code"] == "session_tainted" + assert fresh.continue_processing is True, "labels stay in their session" + assert other.continue_processing is True, "another subject's session is separate" diff --git a/bindings/python/tests/test_builtins.py b/bindings/python/tests/test_builtins.py new file mode 100644 index 00000000..83954514 --- /dev/null +++ b/bindings/python/tests/test_builtins.py @@ -0,0 +1,344 @@ +# Location: ./bindings/python/tests/test_builtins.py +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# The builtin set the wheel ships: registered kinds and hooks, removed +# kinds, OPA and CIBA, the private-destination switch, and secret refresh. + +import json +import threading +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest +from cpex import PolicyEngine + +from _support import WIRE_COMPAT_JSON, tool_call, tool_route + + +@pytest.fixture(scope="module") +def wire_compat() -> dict: + """The kind and hook list shared with the C FFI tests.""" + return json.loads(WIRE_COMPAT_JSON.read_text()) + + +def _write(tmp_path, text: str) -> str: + path = tmp_path / "config.yaml" + path.write_text(text) + return str(path) + + +def _load_error(tmp_path, body: str) -> str: + """The load error for a config, or an empty string when it loads.""" + try: + PolicyEngine(_write(tmp_path, body)) + except ValueError as e: + return str(e) + return "" + + +def _plugin_probe(kind: str, hook: str) -> str: + return ( + "engine_settings:\n dispatch: hooks\n" + f"plugins:\n - name: probe\n kind: {kind}\n hooks: [{hook}]\n" + ) + + +# --------------------------------------------------------------------------- +# Registered kinds and hooks +# --------------------------------------------------------------------------- + + +def test_plugin_kinds_match_the_wire_compat_fixture(tmp_path, wire_compat): + """Every pinned plugin kind resolves; every removed kind does not.""" + for kind in wire_compat["plugin_kinds"]: + err = _load_error(tmp_path, _plugin_probe(kind, "identity.resolve")) + assert "no factory registered" not in err, f"{kind} must resolve; got: {err}" + for kind in wire_compat["absent_plugin_kinds"]: + err = _load_error(tmp_path, _plugin_probe(kind, "identity.resolve")) + assert "no factory registered" in err, f"{kind} must not resolve; got: {err}" + + +def test_pdp_kinds_match_the_wire_compat_fixture(tmp_path, wire_compat): + """Every pinned PDP kind resolves under `global.pdp`.""" + for kind in wire_compat["pdp_kinds"]: + err = _load_error(tmp_path, f"global:\n pdp:\n - kind: {kind}\n") + assert "no factory is registered" not in err, f"{kind} must resolve; got: {err}" + err = _load_error(tmp_path, "global:\n pdp:\n - kind: no-such-pdp\n") + assert "no factory is registered" in err + + +def test_session_store_kinds_match_the_wire_compat_fixture(tmp_path, wire_compat): + """Every pinned session store kind resolves under `global.session_store`.""" + for kind in wire_compat["session_store_kinds"]: + err = _load_error(tmp_path, f"global:\n session_store:\n kind: {kind}\n") + assert "no factory is registered" not in err, f"{kind} must resolve; got: {err}" + err = _load_error(tmp_path, "global:\n session_store:\n kind: no-such-store\n") + assert "no factory is registered" in err + + +@pytest.mark.asyncio +async def test_secret_provider_kinds_match_the_wire_compat_fixture(tmp_path, wire_compat): + """The engine lists its registered provider kinds for an unknown one.""" + eng = PolicyEngine( + _write( + tmp_path, + "engine_settings:\n dispatch: hooks\n" + "secrets:\n providers:\n probe:\n kind: no-such-backend\n" + " values:\n probe_value:\n provider: probe\n ref: anything\n", + ) + ) + with pytest.raises(ValueError) as info: + await eng.initialize() + listed = str(info.value).split("registered kinds are [")[1].split("]")[0] + assert set(listed.split(", ")) == set(wire_compat["secret_provider_kinds"]) + + +def test_hook_names_match_the_wire_compat_fixture(tmp_path, wire_compat): + """Every pinned hook name loads; every removed legacy name is rejected.""" + pinned = {hook for family in wire_compat["hooks"].values() for hook in family} + for hook in pinned: + err = _load_error(tmp_path, _plugin_probe("identity/jwt", hook)) + assert "unknown hook" not in err, f"{hook} must load; got: {err}" + for hook in wire_compat["removed_hooks"]: + err = _load_error(tmp_path, _plugin_probe("identity/jwt", hook)) + assert "unknown hook" in err, f"{hook} must be rejected; got: {err}" + + +@pytest.mark.asyncio +async def test_removed_hook_names_are_refused_at_invoke(passthrough_engine, wire_compat): + """Every removed legacy name raises at invoke time instead of allowing.""" + for hook in wire_compat["removed_hooks"]: + with pytest.raises(ValueError, match=f"'{hook}' was removed; use"): + await passthrough_engine.invoke_hook(hook, {}) + + +@pytest.mark.parametrize("kind", ["validator/pii-scan", "audit/logger"]) +def test_removed_builtin_kind_fails_with_unknown_kind_error(tmp_path, kind): + """A config naming a builtin removed in 0.3 fails to load, naming the kind.""" + path = _write( + tmp_path, + "plugins:\n" + " - name: removed\n" + f" kind: {kind}\n" + " hooks: [cmf.tool_pre_invoke]\n" + "routes:\n" + " - tool: lookup_person\n" + " authorization:\n" + " pre_invocation:\n" + ' - "run(removed)"\n', + ) + with pytest.raises(ValueError, match="no factory registered") as info: + PolicyEngine(path) + assert kind in str(info.value) + + +# --------------------------------------------------------------------------- +# OPA +# --------------------------------------------------------------------------- + +OPA_CONFIG = """ +global: + pdp: + - kind: opa + modules: + - | + package cpex.test + default allow := false + allow if input.args.amount <= 100 + +routes: + - tool: transfer + authorization: + pre_invocation: + - opa: + query: "data.cpex.test.allow" +""" + + +@pytest.mark.asyncio +async def test_opa_pdp_loads_and_decides(tmp_path): + """An in-process Rego module allows and denies by tool argument.""" + eng = PolicyEngine(_write(tmp_path, OPA_CONFIG)) + await eng.initialize() + small = await eng.invoke_hook( + "cmf.tool_pre_invoke", tool_call("transfer", {"amount": 50}), tool_route("transfer") + ) + large = await eng.invoke_hook( + "cmf.tool_pre_invoke", tool_call("transfer", {"amount": 500}), tool_route("transfer") + ) + await eng.shutdown() + + assert small.continue_processing is True + assert large.continue_processing is False + assert large.violation["code"] == "opa" + + +# --------------------------------------------------------------------------- +# CIBA and the private-destination switch +# --------------------------------------------------------------------------- + + +class _MockCiba(BaseHTTPRequestHandler): + """A CIBA OP that accepts every backchannel request and never approves.""" + + seen: list = [] + + def do_POST(self): + length = int(self.headers.get("content-length", 0)) + type(self).seen.append((self.path, self.rfile.read(length).decode())) + if self.path.endswith("/auth"): + status, body = 200, {"auth_req_id": "req-1", "expires_in": 120, "interval": 5} + else: + status, body = 400, {"error": "authorization_pending"} + out = json.dumps(body).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + def log_message(self, *args): + pass + + +@pytest.fixture +def mock_ciba(): + """A loopback CIBA OP. Yields its base URL; records requests in `seen`.""" + _MockCiba.seen = [] + server = HTTPServer(("127.0.0.1", 0), _MockCiba) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}" + server.shutdown() + server.server_close() + + +def _ciba_config(base: str) -> str: + return f""" +plugins: + - name: ciba-approver + kind: elicitation/ciba + hooks: [elicit] + capabilities: [perform_http] + config: + backchannel_endpoint: {base}/ciba/auth + token_endpoint: {base}/token + client_id: cpex-test + client_secret_source: {{kind: literal, secret: test-secret}} + insecure_http: true + +routes: + - tool: send_email + authorization: + pre_invocation: + - 'require_approval(ciba-approver, from: args.approver, purpose: "Approve email")' +""" + + +async def _send_email(eng: PolicyEngine): + extensions = tool_route("send_email") + extensions["security"] = {"subject": {"id": "ciba-requester"}} + return await eng.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("send_email", {"approver": "mona"}), + extensions, + ) + + +@pytest.mark.asyncio +async def test_ciba_reaches_a_loopback_op_with_the_opt_in(tmp_path, mock_ciba): + """With allow_private_destinations=True the backchannel POST goes out.""" + eng = PolicyEngine(_write(tmp_path, _ciba_config(mock_ciba)), allow_private_destinations=True) + await eng.initialize() + result = await _send_email(eng) + await eng.shutdown() + + auth = [body for path, body in _MockCiba.seen if path == "/ciba/auth"] + assert len(auth) == 1, f"one backchannel request expected, saw {_MockCiba.seen}" + assert "login_hint=mona" in auth[0] + # The approval is still pending, so the call does not proceed. + assert result.continue_processing is False + assert "egress_denied" not in result.violation["reason"] + + +@pytest.mark.asyncio +async def test_ciba_is_refused_a_loopback_op_by_default(tmp_path, mock_ciba): + """Without the opt-in the transport refuses loopback and nothing is sent.""" + eng = PolicyEngine(_write(tmp_path, _ciba_config(mock_ciba))) + await eng.initialize() + result = await _send_email(eng) + await eng.shutdown() + + assert _MockCiba.seen == [] + assert result.continue_processing is False + assert "elicitation.egress_denied" in result.violation["reason"] + + +def test_allow_private_destinations_is_keyword_only(tmp_path): + """The switch cannot be passed positionally.""" + path = _write(tmp_path, "plugins: []\n") + with pytest.raises(TypeError): + PolicyEngine(path, True) # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Secret refresh +# --------------------------------------------------------------------------- + + +def _secrets_config(secret_file) -> str: + return ( + "engine_settings:\n dispatch: hooks\n" + "secrets:\n" + " providers:\n local:\n kind: file\n" + f" values:\n api_key:\n provider: local\n ref: {secret_file}\n" + ) + + +@pytest.mark.asyncio +async def test_refresh_secrets_reports_updates_and_failures(tmp_path): + """refresh_secrets returns the FFI report shape; last success tracks health.""" + secret_file = tmp_path / "api_key" + secret_file.write_text("first") + eng = PolicyEngine(_write(tmp_path, _secrets_config(secret_file))) + + assert eng.secrets_last_success("local") is None, "nothing resolved before initialize" + await eng.initialize() + first = eng.secrets_last_success("local") + assert isinstance(first, datetime) + assert first.tzinfo is not None and first.utcoffset() == timezone.utc.utcoffset(None) + assert eng.secrets_last_success("no-such-provider") is None + + unchanged = await eng.refresh_secrets() + assert unchanged == {"updated": [], "unchanged": 1, "failed": []} + + secret_file.write_text("second") + rotated = await eng.refresh_secrets() + assert rotated == {"updated": ["api_key"], "unchanged": 0, "failed": []} + assert eng.secrets_last_success("local") >= first + last_good = eng.secrets_last_success("local") + + secret_file.unlink() + broken = await eng.refresh_secrets() + assert broken["updated"] == [] and broken["unchanged"] == 0 + assert len(broken["failed"]) == 1 + failure = broken["failed"][0] + assert set(failure) == {"secret", "provider", "kind", "message"} + assert failure["secret"] == "api_key" + assert failure["provider"] == "local" + assert failure["kind"] == "not_found" + assert "second" not in failure["message"], "the message never carries the secret" + assert eng.secrets_last_success("local") == last_good, "a failed refresh retains last success" + + await eng.shutdown() + + +@pytest.mark.asyncio +async def test_refresh_secrets_without_a_secrets_block_is_empty(passthrough_engine): + """An engine with no `secrets:` block returns an empty report.""" + assert await passthrough_engine.refresh_secrets() == { + "updated": [], + "unchanged": 0, + "failed": [], + } diff --git a/bindings/python/tests/test_conversions.py b/bindings/python/tests/test_conversions.py index 6ac043fd..331183a3 100644 --- a/bindings/python/tests/test_conversions.py +++ b/bindings/python/tests/test_conversions.py @@ -3,12 +3,12 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Ted Habeck # -# Tests for PyObject ↔ JSON value conversion behaviour (U3). +# Tests for PyObject to JSON value conversion behaviour. # These are exercised through the Python API (invoke_hook with -# various payload shapes) — the Rust internals are not callable directly. +# various payload shapes); the Rust internals are not callable directly. import pytest -from cpex import PluginManager +from cpex import PolicyEngine def _build_generic_payload(value: dict) -> dict: @@ -22,38 +22,38 @@ def _build_generic_payload(value: dict) -> dict: @pytest.mark.asyncio -async def test_flat_dict_round_trip(manager: PluginManager): +async def test_flat_dict_round_trip(passthrough_engine: PolicyEngine): """A flat dict passes through a non-cmf hook unchanged.""" payload = {"key": "value", "num": 42, "flag": True} - result = await manager.invoke_hook("custom.hook", payload) + result = await passthrough_engine.invoke_hook("custom.hook", payload) assert result.continue_processing is True @pytest.mark.asyncio -async def test_nested_dict_round_trip(manager: PluginManager): +async def test_nested_dict_round_trip(passthrough_engine: PolicyEngine): """A nested dict/list passes through without ValueError.""" payload = {"outer": {"inner": [1, 2, 3]}, "text": "hello"} - result = await manager.invoke_hook("custom.hook", payload) + result = await passthrough_engine.invoke_hook("custom.hook", payload) assert result.continue_processing is True @pytest.mark.asyncio -async def test_mixed_scalar_types(manager: PluginManager): +async def test_mixed_scalar_types(passthrough_engine: PolicyEngine): """bool, int, float, str, None all accepted.""" payload = {"b": True, "i": 7, "f": 3.14, "s": "text", "n": None} - result = await manager.invoke_hook("custom.hook", payload) + result = await passthrough_engine.invoke_hook("custom.hook", payload) assert result.continue_processing is True @pytest.mark.asyncio -async def test_empty_dict(manager: PluginManager): - result = await manager.invoke_hook("custom.hook", {}) +async def test_empty_dict(passthrough_engine: PolicyEngine): + result = await passthrough_engine.invoke_hook("custom.hook", {}) assert result.continue_processing is True @pytest.mark.asyncio -async def test_empty_list_in_payload(manager: PluginManager): - result = await manager.invoke_hook("custom.hook", {"items": []}) +async def test_empty_list_in_payload(passthrough_engine: PolicyEngine): + result = await passthrough_engine.invoke_hook("custom.hook", {"items": []}) assert result.continue_processing is True @@ -63,41 +63,86 @@ async def test_empty_list_in_payload(manager: PluginManager): @pytest.mark.asyncio -async def test_nesting_at_128_levels_succeeds(manager: PluginManager): - """Exactly 128 levels deep (depth arg == 128) must succeed — depth > 128 is the guard.""" +async def test_nesting_at_128_levels_succeeds(passthrough_engine: PolicyEngine): + """Exactly 128 levels deep (depth arg == 128) must succeed; depth > 128 is the guard.""" payload: dict = {} current = payload for _ in range(128): # root is depth=0; 128 children → deepest call is depth=128 current["child"] = {} current = current["child"] - result = await manager.invoke_hook("custom.hook", payload) + result = await passthrough_engine.invoke_hook("custom.hook", payload) assert result.continue_processing is True @pytest.mark.asyncio -async def test_nesting_at_129_levels_raises(manager: PluginManager): - """129 levels deep (depth arg == 129 > 128) raises ValueError (R3).""" +async def test_nesting_at_129_levels_raises(passthrough_engine: PolicyEngine): + """129 levels deep (depth arg == 129 > 128) raises ValueError.""" payload: dict = {} current = payload for _ in range(129): # root is depth=0; 129 children → deepest call depth=129 > 128 current["child"] = {} current = current["child"] with pytest.raises(ValueError, match="nesting exceeds maximum depth"): - await manager.invoke_hook("custom.hook", payload) + await passthrough_engine.invoke_hook("custom.hook", payload) @pytest.mark.asyncio -async def test_non_string_key_raises(manager: PluginManager): +async def test_non_string_key_raises(passthrough_engine: PolicyEngine): """A dict with a non-string key must raise ValueError.""" # Cannot construct {1: "v"} as a typed dict but we can via **kwargs trick. bad_payload = {1: "value"} # type: ignore[dict-item] with pytest.raises((ValueError, TypeError)): - await manager.invoke_hook("custom.hook", bad_payload) + await passthrough_engine.invoke_hook("custom.hook", bad_payload) @pytest.mark.asyncio -async def test_unconvertible_type_raises(manager: PluginManager): +async def test_unconvertible_type_raises(passthrough_engine: PolicyEngine): """An unconvertible Python type (set) in the payload raises ValueError.""" bad_payload = {"items": {1, 2, 3}} # type: ignore[dict-item] with pytest.raises(ValueError, match="cannot convert Python object"): - await manager.invoke_hook("custom.hook", bad_payload) + await passthrough_engine.invoke_hook("custom.hook", bad_payload) + + +# --------------------------------------------------------------------------- +# identity.resolve input and output +# --------------------------------------------------------------------------- + + +IDENTITY_INPUT = { + "source": "bearer", + "source_header": "authorization", + "headers": {"authorization": "Bearer not-a-real-token"}, + "client_host": "10.0.0.1", + "client_port": 443, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field, value", + [ + ("subject", {"id": "alice", "roles": ["hr"]}), + ("client", {"client_id": "app"}), + ("caller_workload", {"spiffe_id": "spiffe://example.org/app"}), + ("delegation", {"depth": 0}), + ("raw_credentials", {"inbound_tokens": {}}), + ("resolved_at", "2026-01-01T00:00:00Z"), + ("raw_claims", {"sub": "alice"}), + ], +) +async def test_identity_input_with_resolved_fields_raises(passthrough_engine, field, value): + """A caller cannot pre-fill what the identity resolvers produce.""" + payload = dict(IDENTITY_INPUT, **{field: value}) + with pytest.raises(ValueError, match=f"must not set resolver output fields: {field}"): + await passthrough_engine.invoke_hook("identity.resolve", payload) + + +@pytest.mark.asyncio +async def test_identity_result_does_not_echo_request_headers(passthrough_engine): + """The returned identity payload drops the request headers, credential included.""" + result = await passthrough_engine.invoke_hook("identity.resolve", IDENTITY_INPUT) + assert result.continue_processing is True + assert result.modified_payload is not None + assert "headers" not in result.modified_payload + assert "not-a-real-token" not in repr(result.modified_payload) + assert result.modified_payload["source"] == "bearer" diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index d06fa01a..abf7b3ba 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -3,10 +3,10 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Ted Habeck # -# Error-path tests for cpex-python (U2, U7, KD2, KD7). +# Error-path tests for cpex-python. import pytest -from cpex import PluginManager +from cpex import PolicyEngine # --------------------------------------------------------------------------- @@ -15,43 +15,43 @@ def test_missing_config_file_raises_value_error(): - """Missing config file → ValueError (R2).""" + """Missing config file raises ValueError.""" with pytest.raises(ValueError, match="cannot read config file"): - PluginManager("/nonexistent/path/config.yaml") + PolicyEngine("/nonexistent/path/config.yaml") def test_malformed_yaml_raises_value_error(tmp_path): - """Malformed YAML → ValueError (R2).""" + """Malformed YAML raises ValueError.""" bad_config = tmp_path / "bad.yaml" bad_config.write_text("plugins: [\n - name: broken\n : : bad_yaml") with pytest.raises(ValueError): - PluginManager(str(bad_config)) + PolicyEngine(str(bad_config)) def test_valid_empty_config_constructs_ok(tmp_path): """A valid minimal config (no plugins) constructs without error.""" empty_config = tmp_path / "empty.yaml" empty_config.write_text("plugins: []\n") - mgr = PluginManager(str(empty_config)) - assert mgr is not None + eng = PolicyEngine(str(empty_config)) + assert eng is not None # --------------------------------------------------------------------------- -# Conversion failure (KD2) +# Conversion failure # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_unconvertible_payload_raises_value_error(manager: PluginManager): - """An unconvertible Python type in the payload raises ValueError (KD2, R2).""" +async def test_unconvertible_payload_raises_value_error(passthrough_engine: PolicyEngine): + """An unconvertible Python type in the payload raises ValueError.""" with pytest.raises(ValueError): - await manager.invoke_hook("custom.hook", {"bad": object()}) + await passthrough_engine.invoke_hook("custom.hook", {"bad": object()}) @pytest.mark.asyncio -async def test_invalid_cmf_payload_raises_value_error(manager: PluginManager): +async def test_invalid_cmf_payload_raises_value_error(passthrough_engine: PolicyEngine): """A cmf.* hook with a dict missing the `message` field raises ValueError.""" # MessagePayload requires a `message:` field with a Message struct. # An empty dict (or a dict without `message`) fails serde deserialization. with pytest.raises(ValueError, match="not a valid MessagePayload"): - await manager.invoke_hook("cmf.tool_pre_invoke", {}) + await passthrough_engine.invoke_hook("cmf.tool_pre_invoke", {}) diff --git a/bindings/python/tests/test_manager.py b/bindings/python/tests/test_manager.py index 0bde7efa..020c268f 100644 --- a/bindings/python/tests/test_manager.py +++ b/bindings/python/tests/test_manager.py @@ -3,27 +3,60 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Ted Habeck # -# Integration tests for PyPluginManager lifecycle (U4). +# Integration tests for the PolicyEngine lifecycle. + +import asyncio import pytest -from cpex import PluginManager, PipelineResult +from cpex import PipelineResult, PluginManager, PolicyEngine + +from _support import tool_call, tool_route # --------------------------------------------------------------------------- -# AE1 — importable (KD11 guard test) +# Import surface # --------------------------------------------------------------------------- def test_cpex_lib_importable(): - """cpex._lib must be importable and resolve to the native extension (KD11).""" + """cpex._lib must be importable and resolve to the native extension.""" import cpex import cpex._lib # noqa: F401 - # __file__ on the package must NOT point to the legacy ./cpex/ directory. - # In the test venv, cpex.__file__ should end with cpex/__init__.py from - # the bindings package, and cpex._lib is a native .so/.dylib. - assert hasattr(cpex._lib, "PluginManager"), "cpex._lib must expose PluginManager" + assert hasattr(cpex._lib, "PolicyEngine"), "cpex._lib must expose PolicyEngine" assert hasattr(cpex._lib, "PipelineResult"), "cpex._lib must expose PipelineResult" + assert set(cpex.__all__) == {"PolicyEngine", "PluginManager", "PipelineResult"} + + +def test_plugin_manager_is_an_alias_of_policy_engine(): + """The pre-0.3 name and the new name are the same class.""" + assert PluginManager is PolicyEngine + assert PolicyEngine.__name__ == "PolicyEngine" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cls", [PolicyEngine, PluginManager], ids=["PolicyEngine", "PluginManager"]) +async def test_both_names_load_and_run_a_migrated_config(cls, pii_deny_config_path: str): + """Either name loads a PPE-format config and enforces it.""" + eng = cls(pii_deny_config_path) + assert isinstance(eng, PolicyEngine) + await eng.initialize() + + denied = await eng.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"ssn": "123-45-6789"}), + tool_route("lookup_person"), + ) + allowed = await eng.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"name": "Alice"}), + tool_route("lookup_person"), + ) + await eng.shutdown() + + assert denied.continue_processing is False + assert denied.violation["code"] == "pii.ssn_detected" + assert allowed.continue_processing is True # --------------------------------------------------------------------------- @@ -33,67 +66,68 @@ def test_cpex_lib_importable(): @pytest.mark.asyncio async def test_lifecycle_construct_initialize_shutdown(pii_deny_config_path: str): - """Construct → initialize → shutdown completes without error.""" - mgr = PluginManager(pii_deny_config_path) - await mgr.initialize() - await mgr.shutdown() + """Construct, initialize and shut down without error.""" + eng = PolicyEngine(pii_deny_config_path) + await eng.initialize() + await eng.shutdown() @pytest.mark.asyncio -async def test_invoke_returns_pipeline_result(manager: PluginManager): +async def test_invoke_returns_pipeline_result(engine: PolicyEngine): """A non-triggering invoke returns a PipelineResult with continue_processing=True.""" - payload = { - "message": { - "role": "user", - "content": [{"content_type": "text", "text": "Hello, world!"}], - } - } - result = await manager.invoke_hook("cmf.tool_pre_invoke", payload) + result = await engine.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"name": "Alice"}), + tool_route("lookup_person"), + ) assert isinstance(result, PipelineResult) - # SSN not present — no denial assert result.continue_processing is True # --------------------------------------------------------------------------- -# AE2 — pii-scan deny (KD10, KD4) +# Deny # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_pii_deny_returns_violation(pii_deny_config_path: str): - """AE2: CMF invoke with SSN payload → continue_processing=False, violation present.""" - mgr = PluginManager(pii_deny_config_path) - await mgr.initialize() - - # Payload contains a tool call with an SSN in the arguments. - payload = { - "message": { - "role": "assistant", - "content": [ - { - "content_type": "tool_call", - "content": { - "tool_call_id": "tc_001", - "name": "lookup_person", - "arguments": {"ssn": "123-45-6789"}, - }, - } - ], - } - } - result = await mgr.invoke_hook("cmf.tool_pre_invoke", payload) - - assert result.continue_processing is False, "pii-scan deny should halt pipeline" +async def test_pii_deny_returns_violation(engine: PolicyEngine): + """A tool call with an SSN argument halts the pipeline with a violation.""" + result = await engine.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"ssn": "123-45-6789"}), + tool_route("lookup_person"), + ) + + assert result.continue_processing is False, "the SSN check should halt the pipeline" assert result.violation is not None, "violation dict must be populated" - assert "reason" in result.violation, "violation must have a reason field" + assert result.violation["code"] == "pii.ssn_detected" + assert result.violation["reason"] == "ssn found in tool arguments" - await mgr.shutdown() + +@pytest.mark.asyncio +async def test_route_is_selected_by_meta(engine: PolicyEngine): + """Under policy dispatch the route comes from meta, not the payload. + + A request with no meta cannot be matched to a route, so it is refused + rather than let through unchecked. A request naming a tool with no + route has no policy to apply and is allowed. + """ + payload = tool_call("lookup_person", {"ssn": "123-45-6789"}) + unidentified = await engine.invoke_hook("cmf.tool_pre_invoke", payload) + other_tool = await engine.invoke_hook( + "cmf.tool_pre_invoke", payload, tool_route("other_tool") + ) + assert unidentified.continue_processing is False + assert unidentified.violation["code"] == "unidentified_request" + assert other_tool.continue_processing is True @pytest.mark.asyncio -async def test_generic_hook_does_not_raise(manager: PluginManager): - """Non-CMF hook routes through GenericPayload and returns a result (KD2).""" - result = await manager.invoke_hook("custom.arbitrary.hook", {"data": "value"}) +async def test_generic_hook_does_not_raise(engine: PolicyEngine): + """A non-CMF hook routes through GenericPayload and returns a result.""" + result = await engine.invoke_hook( + "custom.arbitrary.hook", {"data": "value"}, tool_route("lookup_person") + ) assert isinstance(result, PipelineResult) assert result.continue_processing is True @@ -105,42 +139,114 @@ async def test_generic_hook_does_not_raise(manager: PluginManager): @pytest.mark.asyncio async def test_pii_redact_returns_modified_payload(pii_redact_config_path: str): - """Redact mode: plugin mutates payload → modified_payload round-trips through PyO3. + """Redaction rewrites the payload, and the change round-trips through PyO3. Asserts: - - continue_processing is True (redact allows, unlike deny) - - modified_payload is not None (the plugin produced a mutation) - - the SSN argument value is replaced with [PII] in the returned dict + - continue_processing is True (redaction allows, unlike deny) + - payload_modified is True and modified_payload is set + - the SSN argument value is replaced with [REDACTED] - an unrelated argument field is preserved verbatim """ - mgr = PluginManager(pii_redact_config_path) - await mgr.initialize() - - payload = { - "message": { - "role": "assistant", - "content": [ - { - "content_type": "tool_call", - "content": { - "tool_call_id": "tc_001", - "name": "lookup_person", - "arguments": { - "ssn": "123-45-6789", - "name": "Alice", - }, - }, - } - ], - } - } - result = await mgr.invoke_hook("cmf.tool_pre_invoke", payload) - - assert result.continue_processing is True, "redact mode should allow processing" - assert result.modified_payload is not None, "pii-scan redact must set modified_payload" + eng = PolicyEngine(pii_redact_config_path) + await eng.initialize() + + result = await eng.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"ssn": "123-45-6789", "name": "Alice"}), + tool_route("lookup_person"), + ) + + assert result.continue_processing is True, "redaction should allow processing" + assert result.payload_modified is True + assert result.modified_payload is not None, "redaction must set modified_payload" args = result.modified_payload["message"]["content"][0]["content"]["arguments"] - assert args["ssn"] == "[PII]", f"SSN should be redacted, got: {args['ssn']!r}" + assert args["ssn"] == "[REDACTED]", f"SSN should be redacted, got: {args['ssn']!r}" assert args["name"] == "Alice", "unrelated field must be preserved" - await mgr.shutdown() + await eng.shutdown() + + +# --------------------------------------------------------------------------- +# After shutdown +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_invoke_after_shutdown_raises(pii_deny_config_path: str): + """Shutdown is terminal: later calls raise RuntimeError, not a crash.""" + eng = PolicyEngine(pii_deny_config_path) + await eng.initialize() + await eng.shutdown() + + with pytest.raises(RuntimeError, match="after shutdown"): + await eng.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"ssn": "123-45-6789"}), + tool_route("lookup_person"), + ) + with pytest.raises(RuntimeError, match="after shutdown"): + await eng.initialize() + with pytest.raises(RuntimeError, match="after shutdown"): + await eng.refresh_secrets() + + # A second shutdown is a no-op. + await eng.shutdown() + + +# --------------------------------------------------------------------------- +# Removed hook names +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "removed, replacement", + [ + ("tool_pre_invoke", "cmf.tool_pre_invoke"), + ("prompt_pre_fetch", "cmf.prompt_pre_invoke"), + ("cmf.prompt_pre_fetch", "cmf.prompt_pre_invoke"), + ("identity_resolve", "identity.resolve"), + ("token_delegate", "token.delegate"), + ("cmf.http_request", "http.request"), + ], +) +async def test_removed_hook_name_raises_naming_the_replacement( + passthrough_engine: PolicyEngine, removed: str, replacement: str +): + """A 0.2.x hook name at a call site raises instead of allowing unchecked.""" + with pytest.raises(ValueError, match=f"'{removed}' was removed; use '{replacement}'"): + await passthrough_engine.invoke_hook(removed, {}) + + +# --------------------------------------------------------------------------- +# Shutdown ordering +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_shutdown_is_terminal_when_called(passthrough_engine: PolicyEngine): + """Calling shutdown refuses new work before its awaitable is awaited.""" + pending = passthrough_engine.shutdown() + with pytest.raises(RuntimeError, match="after shutdown"): + passthrough_engine.invoke_hook("custom.hook", {}) + await pending + with pytest.raises(RuntimeError, match="after shutdown"): + await passthrough_engine.invoke_hook("custom.hook", {}) + + +@pytest.mark.asyncio +async def test_shutdown_concurrent_with_initialize(pii_deny_config_path: str): + """A shutdown racing initialize leaves the engine shut down, not live.""" + eng = PolicyEngine(pii_deny_config_path) + init = asyncio.ensure_future(eng.initialize()) + await eng.shutdown() + # Either initialize won the lock and ran first, or shutdown did and + # initialize was refused. Both end shut down. + try: + await init + except RuntimeError as err: + assert "after shutdown" in str(err) + with pytest.raises(RuntimeError, match="after shutdown"): + await eng.initialize() + await eng.shutdown() diff --git a/bindings/python/tests/test_result.py b/bindings/python/tests/test_result.py index a0f06597..5e48a290 100644 --- a/bindings/python/tests/test_result.py +++ b/bindings/python/tests/test_result.py @@ -3,12 +3,14 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Ted Habeck # -# Tests for PyPipelineResult field access and repr (U5). +# Tests for PipelineResult field access and repr. import re import pytest -from cpex import PluginManager, PipelineResult +from cpex import PipelineResult, PolicyEngine + +from _support import text_message, tool_call, tool_route # --------------------------------------------------------------------------- @@ -17,81 +19,63 @@ @pytest.mark.asyncio -async def test_all_fields_accessible(manager: PluginManager): - """All seven PipelineResult fields are accessible after a real invoke.""" - payload = { - "message": { - "role": "user", - "content": [{"content_type": "text", "text": "Hello"}], - } - } - result = await manager.invoke_hook("cmf.tool_pre_invoke", payload) +async def test_all_fields_accessible(engine: PolicyEngine): + """All PipelineResult fields are accessible after a real invoke.""" + result = await engine.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"name": "Alice"}), + tool_route("lookup_person"), + ) assert isinstance(result, PipelineResult) # continue_processing assert isinstance(result.continue_processing, bool) - # violation — None for an allowed result + # violation: None for an allowed result assert result.violation is None - # errors — list (may be empty) + # errors: list (may be empty) assert isinstance(result.errors, list) - # metadata — may be None + # metadata: may be None assert result.metadata is None or isinstance(result.metadata, dict) - # context_table — always a dict + # context_table: always a dict assert isinstance(result.context_table, dict) # --------------------------------------------------------------------------- -# Deny result — violation dict shape +# Deny result: violation dict shape # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_deny_result_violation_fields(pii_deny_config_path: str): """A denied result exposes violation dict with expected keys.""" - mgr = PluginManager(pii_deny_config_path) - await mgr.initialize() - - payload = { - "message": { - "role": "assistant", - "content": [ - { - "content_type": "tool_call", - "content": { - "tool_call_id": "tc_x", - "name": "submit_form", - "arguments": {"ssn": "987-65-4321"}, - }, - } - ], - } - } - result = await mgr.invoke_hook("cmf.tool_pre_invoke", payload) + eng = PolicyEngine(pii_deny_config_path) + await eng.initialize() + + result = await eng.invoke_hook( + "cmf.tool_pre_invoke", + tool_call("lookup_person", {"ssn": "987-65-4321"}, call_id="tc_x"), + tool_route("lookup_person"), + ) assert result.continue_processing is False assert result.violation is not None assert isinstance(result.violation, dict) # Standard violation keys assert "reason" in result.violation + assert "code" in result.violation - await mgr.shutdown() + await eng.shutdown() # --------------------------------------------------------------------------- -# repr safety — no pointer leakage (R3) +# repr safety: no pointer leakage # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_repr_no_pointers(manager: PluginManager): - """__repr__ must not contain hex pointer substrings (R3).""" - payload = { - "message": { - "role": "user", - "content": [{"content_type": "text", "text": "hi"}], - } - } - result = await manager.invoke_hook("cmf.tool_pre_invoke", payload) +async def test_repr_no_pointers(engine: PolicyEngine): + """__repr__ must not contain hex pointer substrings.""" + result = await engine.invoke_hook("cmf.tool_pre_invoke", text_message("hi")) r = repr(result) # Hex-pointer pattern: 0x followed by hex digits assert not re.search(r"0x[0-9a-fA-F]+", r), f"repr contains pointer: {r!r}" diff --git a/builtins/pdps/cedar-direct/Cargo.toml b/builtins/pdps/cedar-direct/Cargo.toml deleted file mode 100644 index 2fe103ec..00000000 --- a/builtins/pdps/cedar-direct/Cargo.toml +++ /dev/null @@ -1,65 +0,0 @@ -# Location: ./builtins/pdps/cedar-direct/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-pdp-cedar-direct — a `PdpResolver` implementation that wraps the bare -# `cedar-policy` crate (Amazon's Cedar engine, no JWT validation, no policy -# store loading, no Lock Server integration). -# -# Use when the host already has identity validated (via gateway, SPIFFE, -# prior plugin, or JWT validation) and policies are loaded as text/files at -# startup and don't change at runtime. Smallest dep tree; ~5 transitive -# crates. - -[package] -name = "cpex-pdp-cedar-direct" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX PDP — Amazon Cedar policy evaluation." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -apl-core = { workspace = true } -# Permissive caret spec — `"4"` means "any 4.x that Cargo can find." -# -# Code-side note: we use `Request::new(...)` (added in 4.11 alongside -# the deprecated builder; still available in older 4.x via the -# constructor form). Tracked separately if we ever need to support -# pre-4.x or post-5.x. -cedar-policy = "4" -# Stack-growth guard for the cedar evaluation path. cedar-policy-core aborts -# with "recursion limit reached" when `stacker::remaining_stack()` is below -# ~100 KiB; hosts that hand the FFI a small thread stack (musl defaults to -# 128 KiB) trip that on inputs glibc's 8 MiB stack handles fine. We use the -# same crate cedar does — 0.1.x dedups with cedar's transitive pin — to grow -# onto a fresh segment before calling in. See `evaluate`. -stacker = "0.1" -async-trait = { workspace = true } -serde_json = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -# End-to-end integration tests wire the cedar-direct factory through the -# apl-cpex visitor and exercise it against a real `PluginManager`. These -# dev-dep edges only exist for tests — the crate itself stays -# apl-core-only at compile time so it can be used standalone (e.g. in a -# custom orchestrator that doesn't go through apl-cpex at all). -apl-cmf = { workspace = true } -apl-cpex = { workspace = true } -cpex-core = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } -# Minimal executor for the small-stack regression test (tests/small_stack_eval.rs): -# drives the async `evaluate` without tokio's larger per-call stack footprint, so -# the 128 KiB test thread exercises the maybe_grow path, not the runtime. -futures = { workspace = true } - -[lints] -workspace = true diff --git a/builtins/pdps/cedar-direct/src/cedar_attrs.rs b/builtins/pdps/cedar-direct/src/cedar_attrs.rs deleted file mode 100644 index 354c1873..00000000 --- a/builtins/pdps/cedar-direct/src/cedar_attrs.rs +++ /dev/null @@ -1,61 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/cedar_attrs.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Canonical Cedar entity attribute names. -// -// Cedar policy authors write `principal.roles.contains("hr")`, -// `principal.permissions.contains("view_ssn")`, etc. — the strings on -// the right side of `principal.` are *Cedar entity attribute names* -// that this crate produces when it builds the principal entity from the -// `AttributeBag`. Author-facing vocabulary, distinct from the -// `apl-cmf::constants::BAG_*` bag-key vocabulary even when the words -// happen to match. -// -// Keeping these constants in one module means a rename ripples to a -// single file. The entity builder in `entities.rs` and any future -// schema generator both reference them by symbol. -// -// Pair this list with the schema published to Cedar authors — every -// constant here should appear in any official entity schema. - -/// `id` — the entity's identifier attribute (we emit it inside `attrs` -/// for legibility even though Cedar also has it in the `uid` slot). -pub const ATTR_ID: &str = "id"; - -/// `type` — the entity's type name as a string, for policies that -/// branch on subject kind (`principal.type == "agent"` etc.). -pub const ATTR_TYPE: &str = "type"; - -/// `roles` — `Set` of role names the principal holds. -/// Filled from `apl-cmf`'s `role.*` bag keys. -pub const ATTR_ROLES: &str = "roles"; - -/// `permissions` — `Set` of permission names. -/// Filled from `apl-cmf`'s `perm.*` bag keys. -pub const ATTR_PERMISSIONS: &str = "permissions"; - -/// `teams` — `Set` of team / group memberships. -/// Filled from `apl-cmf`'s `subject.teams` bag key. -pub const ATTR_TEAMS: &str = "teams"; - -/// `claims` — `Record` of arbitrary JWT-style claims. Filled from -/// `apl-cmf`'s `claim.*` bag keys. -pub const ATTR_CLAIMS: &str = "claims"; - -// JSON wrapping keys (Cedar's entity-from-JSON shape). -// -// These aren't entity attributes per se — they're the top-level -// keys of the JSON shape Cedar expects when reading an entity from -// `Entity::from_json_value`. Kept here so the entity-builder code -// stays free of magic strings. - -/// `uid` — the {type, id} envelope at the top of an entity JSON. -pub const KEY_UID: &str = "uid"; - -/// `attrs` — the attribute bag inside an entity JSON. -pub const KEY_ATTRS: &str = "attrs"; - -/// `parents` — the optional parents list inside an entity JSON. -pub const KEY_PARENTS: &str = "parents"; diff --git a/builtins/pdps/cedar-direct/src/decision.rs b/builtins/pdps/cedar-direct/src/decision.rs deleted file mode 100644 index 3735b34b..00000000 --- a/builtins/pdps/cedar-direct/src/decision.rs +++ /dev/null @@ -1,123 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/decision.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Translation from `cedar_policy::Response` into `apl_core::PdpDecision`. -// -// What we preserve: -// -// - `decision` — Allow ↔ Deny. One-to-one. -// - `diagnostics` — the set of policy IDs that *determined* the -// decision (not "matched" — Cedar's `reason()` is -// the policies whose effect produced the outcome). -// Operators who annotated their policies with -// `@id("...")` get meaningful identifiers; without -// annotations they get `policy0`, `policy1`, …. -// - `rule_source` — first policy ID from `diagnostics`. Becomes the -// violation code on Deny so audit logs / wire -// errors say "denied via owner-override" rather -// than "cedar.deny." -// -// What we drop (for now): -// -// - Obligations — Cedar 4.10 doesn't have first-class obligations. -// Policy annotations could carry them (`@obligation(...)`) but -// wiring the annotation vocabulary is deferred. -// -// # Fail-closed on evaluation errors -// -// Cedar's `Response::diagnostics().errors()` lists policies that errored -// during runtime evaluation (e.g. type errors in a `when` clause that -// only manifest with certain entity data). If ANY policy errored, we -// return Deny regardless of what `decision()` says — an untrusted -// decision is worse than a closed gate. The error messages flow into -// the Deny reason so operators see why. - -use apl_core::evaluator::Decision; -use apl_core::step::PdpDecision; -use cedar_policy::{Decision as CedarDecision, PolicySet}; - -/// Translate a `cedar_policy::Response` into the APL-side `PdpDecision`. -/// Captures policy-ID attribution into `diagnostics` and, on Deny, -/// surfaces the first firing policy as the `rule_source`. -/// -/// # `@id` annotation lookup -/// -/// `PolicySet::from_str` assigns auto-IDs (`policy0`, `policy1`, ...); -/// authors get *meaningful* identifiers by annotating each policy with -/// `@id("my-rule")`. We resolve auto-IDs to annotation values here so -/// the rest of the system sees the names operators chose. Policies -/// without `@id` annotations keep their auto-IDs — explicit-is-better -/// fallback rather than silent translation. -pub fn translate(response: &cedar_policy::Response, policy_set: &PolicySet) -> PdpDecision { - let diagnostics = response.diagnostics(); - - let firing_policies: Vec = diagnostics - .reason() - .map(|pid| { - // Prefer the operator-supplied `@id("...")` annotation; - // fall back to Cedar's auto-generated id when the policy - // is unannotated. - policy_set - .policy(pid) - .and_then(|p| p.annotation("id")) - .map(|s| s.to_string()) - .unwrap_or_else(|| pid.to_string()) - }) - .collect(); - - let errors: Vec = diagnostics.errors().map(|e| e.to_string()).collect(); - - // Fail-closed: any runtime evaluation error → Deny with the error - // text so the operator sees what went wrong. Cedar's own - // `decision()` may still say Allow when errors occurred; we override - // because an Allow on a partially-failed evaluation isn't - // trustworthy. - if !errors.is_empty() { - let reason = format!( - "Cedar evaluation produced errors (fail-closed): {}", - errors.join("; ") - ); - let rule_source = firing_policies - .first() - .cloned() - .unwrap_or_else(|| "cedar.evaluation_error".to_string()); - return PdpDecision { - decision: Decision::Deny { - reason: Some(reason), - rule_source, - }, - diagnostics: firing_policies, - }; - } - - let decision = match response.decision() { - CedarDecision::Allow => Decision::Allow, - CedarDecision::Deny => { - // Build a human-readable reason from the firing policies so - // wire errors and audit logs carry attribution. First - // policy ID becomes the violation code. - let reason = if firing_policies.is_empty() { - // Cedar deny with no firing policy means no `permit` - // matched — the "default deny" case. - "no Cedar permit policy matched the request".to_string() - } else { - format!("denied by Cedar policy: {}", firing_policies.join(", ")) - }; - let rule_source = firing_policies - .first() - .cloned() - .unwrap_or_else(|| "cedar.default_deny".to_string()); - Decision::Deny { - reason: Some(reason), - rule_source, - } - }, - }; - - PdpDecision { - decision, - diagnostics: firing_policies, - } -} diff --git a/builtins/pdps/cedar-direct/src/entities.rs b/builtins/pdps/cedar-direct/src/entities.rs deleted file mode 100644 index 616b99cc..00000000 --- a/builtins/pdps/cedar-direct/src/entities.rs +++ /dev/null @@ -1,248 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/entities.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Build a `cedar_policy::Entities` set from: -// -// - The `AttributeBag` — APL's view of `SecurityExtension` etc. -// populated upstream by apl-cmf. Source of the **principal** entity. -// - `PdpCall.args.resource` — the resource description the policy -// author wrote in the `cedar:(...)` step. Source of the **resource** -// entity. -// -// v0 builds a minimum-viable entity set: just principal + resource, -// no hierarchy (no `User in Team`, no `Document in Folder`). Operators -// who need that plug an `EntityProvider` trait we'll add later — when -// there's a real use case driving the design. -// -// # Why JSON-shaped construction -// -// Cedar's `Entity::from_json_value(json, schema)` accepts a record -// with `uid`, `attrs`, `parents` keys. We build that record from the -// bag / args and let Cedar's parser handle the attribute-value -// translation (string → String, JSON array of strings → Set, -// nested object → Record, etc.). Avoids fighting with -// `RestrictedExpression` directly. - -use std::collections::HashSet; - -use apl_core::attributes::{AttributeBag, AttributeValue}; -use apl_core::step::PdpError; -use cedar_policy::{Entities, Entity, Schema}; -use serde_json::{json, Map, Value}; - -use crate::cedar_attrs::{ - ATTR_CLAIMS, ATTR_ID, ATTR_PERMISSIONS, ATTR_ROLES, ATTR_TEAMS, ATTR_TYPE, KEY_ATTRS, - KEY_PARENTS, KEY_UID, -}; - -/// Build the entity set for one Cedar request. Returns owned -/// `Entities` (Cedar takes them by reference at authorization time). -pub fn build( - bag: &AttributeBag, - resource_args: &serde_yaml::Value, - schema: Option<&Schema>, - entity_namespace: Option<&str>, -) -> Result { - let principal = build_principal(bag, schema, entity_namespace)?; - let resource = build_resource(resource_args, schema)?; - Entities::from_entities([principal, resource], schema) - .map_err(|e| PdpError::Dispatch(format!("failed to assemble Cedar entity set: {}", e))) -} - -/// Build the principal `Entity` from the bag. Reads: -/// -/// - `subject.id` → entity id (required) -/// - `subject.type` → entity type ("User" | "Agent" | "Service" | -/// "System"); defaults to "User" when absent -/// - `role.=true` → `attrs.roles : Set` -/// - `perm.=true` → `attrs.permissions : Set` -/// - `claim.=v` → `attrs.claims.` (record) -/// - `subject.teams` → `attrs.teams : Set` -/// -/// Operators with custom claim attributes write their Cedar policies -/// against `principal.claims.foo` — those land via the `claim.foo` bag -/// key, populated upstream by apl-cmf from `SubjectExtension.claims`. -pub fn build_principal( - bag: &AttributeBag, - schema: Option<&Schema>, - entity_namespace: Option<&str>, -) -> Result { - let id = bag - .get_string("subject.id") - .ok_or_else(|| { - PdpError::Dispatch( - "Cedar request needs a principal but bag has no `subject.id` — \ - install an identity-hook plugin upstream of APL policy" - .to_string(), - ) - })? - .to_string(); - - let kind = bag.get_string("subject.type").unwrap_or("User"); - let entity_type = qualify_type(kind, entity_namespace); - - // Collect attributes from the bag. We pick the well-known shapes; - // arbitrary `subject.*` keys beyond these are intentionally NOT - // surfaced — operators with custom shapes use `claim.*` or extend - // the bridge. - // - // Empty defaults matter: Cedar's strict-evaluation mode raises a - // runtime error when a policy probes a missing attribute - // (`principal.roles.contains(...)` against a principal without - // `roles`). The resolver's fail-closed logic would then deny — - // surprising for policy authors who expect missing-attribute → - // empty-set semantics. Populating empty sets / records by default - // gives clean "attribute exists, just empty" behavior. - let mut attrs = Map::new(); - attrs.insert(ATTR_ID.to_string(), json!(id)); - attrs.insert(ATTR_TYPE.to_string(), json!(kind)); - - // TODO(vocab consolidation, Phase C): `"role."`, `"perm."`, and - // `"subject.teams"` are apl-cmf bag-key conventions. The cedar - // crate would need a dependency on apl-cmf (or the BAG_* constants - // need to move into apl-core / a shared crate) before we can - // reference them by symbol here. Left literal for now — the gap is - // tracked in the `project_vocab_consolidation` memory. - let roles = collect_prefixed_bools(bag, "role."); - attrs.insert(ATTR_ROLES.to_string(), json!(roles)); - - let permissions = collect_prefixed_bools(bag, "perm."); - attrs.insert(ATTR_PERMISSIONS.to_string(), json!(permissions)); - - let teams: Vec = bag - .get_string_set("subject.teams") - .map(|s| s.iter().cloned().collect()) - .unwrap_or_default(); - attrs.insert(ATTR_TEAMS.to_string(), json!(teams)); - - let claims = collect_claims(bag); - attrs.insert(ATTR_CLAIMS.to_string(), Value::Object(claims)); - - let mut uid_obj = Map::new(); - uid_obj.insert(ATTR_TYPE.to_string(), json!(entity_type)); - uid_obj.insert(ATTR_ID.to_string(), json!(id)); - let mut entity_obj = Map::new(); - entity_obj.insert(KEY_UID.to_string(), Value::Object(uid_obj)); - entity_obj.insert(KEY_ATTRS.to_string(), Value::Object(attrs)); - entity_obj.insert(KEY_PARENTS.to_string(), Value::Array(vec![])); - let entity_json = Value::Object(entity_obj); - - Entity::from_json_value(entity_json, schema).map_err(|e| { - PdpError::Dispatch(format!( - "failed to construct principal entity '{}::\"{}\"': {}", - entity_type, id, e - )) - }) -} - -/// Build the resource `Entity` from the policy author's `args.resource` -/// block. Shape: -/// -/// ```yaml -/// resource: -/// type: Document # required, Cedar entity type -/// id: doc-42 # required, entity id (string) -/// attributes: # optional, key → JSON value -/// classification: internal -/// owner: 'User::"alice"' -/// ``` -pub fn build_resource( - resource_args: &serde_yaml::Value, - schema: Option<&Schema>, -) -> Result { - let map = resource_args.as_mapping().ok_or_else(|| { - PdpError::Dispatch( - "cedar:() `resource` must be a mapping with `type` and `id` keys".to_string(), - ) - })?; - - let entity_type = yaml_string(map, "type").ok_or_else(|| { - PdpError::Dispatch("cedar:() `resource.type` missing or not a string".to_string()) - })?; - let id = yaml_string(map, "id").ok_or_else(|| { - PdpError::Dispatch("cedar:() `resource.id` missing or not a string".to_string()) - })?; - - let attrs_value = map - .get(serde_yaml::Value::String("attributes".to_string())) - .cloned() - .unwrap_or(serde_yaml::Value::Mapping(Default::default())); - let attrs_json: Value = serde_json::to_value(&attrs_value).map_err(|e| { - PdpError::Dispatch(format!( - "cedar:() `resource.attributes` not JSON-representable: {}", - e - )) - })?; - - let mut uid_obj = Map::new(); - uid_obj.insert(ATTR_TYPE.to_string(), json!(entity_type)); - uid_obj.insert(ATTR_ID.to_string(), json!(id)); - let mut entity_obj = Map::new(); - entity_obj.insert(KEY_UID.to_string(), Value::Object(uid_obj)); - entity_obj.insert(KEY_ATTRS.to_string(), attrs_json); - entity_obj.insert(KEY_PARENTS.to_string(), Value::Array(vec![])); - let entity_json = Value::Object(entity_obj); - - Entity::from_json_value(entity_json, schema).map_err(|e| { - PdpError::Dispatch(format!( - "failed to construct resource entity '{}::\"{}\"': {}", - entity_type, id, e - )) - }) -} - -/// Apply the optional namespace to a bare entity type. `Some("Acme")` + -/// `"User"` → `"Acme::User"`. `None` → `"User"`. Lets operators with -/// namespaced schemas (`Acme::User`, `Acme::Document`) work without -/// each policy author having to hand-prefix everywhere. -fn qualify_type(bare: &str, namespace: Option<&str>) -> String { - match namespace { - Some(ns) if !ns.is_empty() => format!("{}::{}", ns, bare), - _ => bare.to_string(), - } -} - -/// Read every `X = true` key from the bag and return `[X, ...]`. -/// Used for `role.*` → roles and `perm.*` → permissions, matching -/// apl-cmf's presence-only encoding for role / permission membership. -fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { - let mut out: HashSet = HashSet::new(); - for (key, value) in bag.iter() { - if let Some(name) = key.strip_prefix(prefix) { - if matches!(value, AttributeValue::Bool(true)) { - out.insert(name.to_string()); - } - } - } - let mut v: Vec = out.into_iter().collect(); - v.sort(); - v -} - -/// Read every `claim.` key and assemble a JSON record of the -/// values. Each claim's value type comes through as JSON (`Bool`, -/// `String`, etc.) so Cedar's record-of-records story works. -fn collect_claims(bag: &AttributeBag) -> Map { - let mut out = Map::new(); - for (key, value) in bag.iter() { - if let Some(name) = key.strip_prefix("claim.") { - let v = match value { - AttributeValue::Bool(b) => json!(*b), - AttributeValue::Int(i) => json!(*i), - AttributeValue::Float(f) => json!(*f), - AttributeValue::String(s) => json!(s), - AttributeValue::StringSet(set) => json!(set.iter().collect::>()), - }; - out.insert(name.to_string(), v); - } - } - out -} - -fn yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { - map.get(serde_yaml::Value::String(key.to_string()))? - .as_str() - .map(|s| s.to_string()) -} diff --git a/builtins/pdps/cedar-direct/src/error.rs b/builtins/pdps/cedar-direct/src/error.rs deleted file mode 100644 index eba02858..00000000 --- a/builtins/pdps/cedar-direct/src/error.rs +++ /dev/null @@ -1,67 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/error.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Build-time errors for `CedarDirectResolver`. All variants fire at -// construction (parse, validate, load); never at request time. -// -// Request-time errors flow through `apl_core::PdpError` because that's -// the trait's return type. The two error stories are deliberately -// separate — build errors are config faults the operator fixes once; -// request errors are per-evaluation issues the host has to handle -// continuously. -// -// `BuildError` implements `std::error::Error` (via thiserror), so it -// boxes cleanly into `apl_cpex::visitor::VisitorError` when the -// AplConfigVisitor builds a resolver from a unified-config block. The -// visitor then wraps that into `cpex_core::PluginError::Config` on its -// way out of `load_config_yaml`. Each layer wraps the layer below using -// its own native error type — no dep inversion required to make the -// error flow work. - -use thiserror::Error; - -/// Error returned at resolver construction. -#[derive(Debug, Error)] -pub enum BuildError { - /// The policy text didn't parse as Cedar. Carries the underlying - /// parser message verbatim so operators can see exactly which - /// `permit`/`forbid` line broke. - #[error("failed to parse Cedar policy set: {0}")] - PolicyParse(String), - - /// Cedar accepted the policy text but the schema (if supplied) - /// rejected one or more policies as invalid against the declared - /// entity / action shape. - #[error("policy set failed schema validation: {0}")] - PolicyValidation(String), - - /// I/O failure reading a policy file from disk. Distinct variant - /// from `PolicyParse` so operators can tell "file not found" from - /// "file found but unparseable" without grepping the message. - #[error("failed to read Cedar policy file '{path}': {source}")] - PolicyFile { - path: String, - #[source] - source: std::io::Error, - }, - - /// Schema text didn't parse as Cedar schema. - #[error("failed to parse Cedar schema: {0}")] - SchemaParse(String), - - /// I/O failure reading a schema file from disk. - #[error("failed to read Cedar schema file '{path}': {source}")] - SchemaFile { - path: String, - #[source] - source: std::io::Error, - }, - - /// Config block missing required fields, or fields had the wrong - /// shape. Fired by `from_config(&serde_yaml::Value)` when the - /// operator's YAML doesn't match the expected layout. - #[error("invalid Cedar PDP config: {0}")] - ConfigShape(String), -} diff --git a/builtins/pdps/cedar-direct/src/factory.rs b/builtins/pdps/cedar-direct/src/factory.rs deleted file mode 100644 index 4200e6aa..00000000 --- a/builtins/pdps/cedar-direct/src/factory.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/factory.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CedarDirectPdpFactory` — the `PdpFactory` implementation that lets -// the apl-cpex visitor instantiate `CedarDirectResolver` from a -// unified-config YAML block: -// -// ```yaml -// global: -// pdp: -// - kind: cedar-direct -// dialect: cedar # optional, defaults to PdpDialect::Cedar -// policy_text: | # required (or policy_file) -// @id("owner-override") -// permit(...); -// ``` -// -// Hosts register an instance of this factory in `AplOptions.pdp_factories`; -// the visitor matches it to the block by `kind` and dispatches. - -use std::sync::Arc; - -use apl_core::step::{PdpFactory, PdpResolver}; - -use crate::resolver::CedarDirectResolver; - -/// Factory for `CedarDirectResolver`. Reports `kind() = "cedar-direct"`; -/// builds resolvers from the unified-config block via -/// [`CedarDirectResolver::from_config`]. -#[derive(Default)] -pub struct CedarDirectPdpFactory; - -impl CedarDirectPdpFactory { - pub fn new() -> Self { - Self - } -} - -impl PdpFactory for CedarDirectPdpFactory { - fn kind(&self) -> &str { - "cedar-direct" - } - - fn build( - &self, - config: &serde_yaml::Value, - ) -> Result, Box> { - let resolver = CedarDirectResolver::from_config(config)?; - Ok(Arc::new(resolver)) - } -} diff --git a/builtins/pdps/cedar-direct/src/lib.rs b/builtins/pdps/cedar-direct/src/lib.rs deleted file mode 100644 index 864820e8..00000000 --- a/builtins/pdps/cedar-direct/src/lib.rs +++ /dev/null @@ -1,113 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-pdp-cedar-direct — `PdpResolver` over the bare `cedar-policy` crate. -// -// # Where this lives in the stack -// -// APL evaluator (apl-core) -// │ `cedar:(action:..., resource:..., context:...)` step -// ▼ -// PdpRouter (apl-cpex) — dispatches by dialect -// │ resolver.evaluate(call, bag) -// ▼ -// CedarDirectResolver — THIS CRATE -// │ translate to cedar_policy::Request + Entities -// ▼ -// cedar_policy::Authorizer — Amazon's official Cedar evaluator -// -// # Inputs (`PdpCall.args`) -// -// APL routes call cedar like: -// -// ```yaml -// policy: -// - cedar: -// action: 'Action::"read"' -// resource: -// type: Document -// id: doc-42 -// attributes: -// classification: internal -// owner: 'User::"alice"' -// context: -// request_time: "2026-05-18T10:00:00Z" -// ``` -// -// Required keys: `action`, `resource.type`, `resource.id`. Optional: -// `resource.attributes`, `context`. Principal is NOT in `args` — see -// below. -// -// # Principal -// -// The principal entity is built from the `AttributeBag` that apl-cmf -// populated from `SecurityExtension.subject`: -// -// - `subject.id` → entity id (required; missing → request-time error) -// - `subject.type` → entity type ("User", "Agent", "Service", "System"); -// defaults to "User" when absent -// - `role.=true` → principal.roles : Set -// - `perm.=true` → principal.permissions : Set -// - `claim.=v` → principal.claims. = v -// - `subject.teams` → principal.teams : Set -// - `subject.id` → principal.id : String -// -// Operators with richer principal shapes (custom JWT claims, workload -// trust domains) populate them upstream via identity-hook plugins; this -// crate just reads what the bag carries. -// -// # CPEX-provided context -// -// In addition to whatever the policy author put in `args.context`, the -// resolver merges in well-known CPEX context paths so policies can -// reason about them with a stable schema: -// -// - `context.delegation` — `{ chain: [...], depth: N }` from -// `DelegationExtension` (via bag's `delegation.*`). -// - `context.meta` — `{ entity_type, entity_name, scope, tags }` -// from `MetaExtension`. -// - `context.security` — `{ labels: [...], classification }`. -// -// Operators document this layout in their Cedar schema; policy authors -// rely on it. -// -// # Schema (optional) -// -// Cedar schemas validate policies at load time and requests at -// evaluation time. Recommended for production deployments; skipped here -// by default to keep the construction surface simple. Add via -// `CedarDirectResolver::with_schema(schema)`. -// -// # Decision attribution -// -// Cedar's `Response::diagnostics().reason()` returns the policy IDs of -// every policy that determined the decision. These flow back through -// `PdpDecision.diagnostics`, and the first one becomes the -// `rule_source` on Deny — so APL violations carry "denied via -// owner-override" instead of an opaque "cedar.deny." -// -// Policy authors should annotate every policy with `@id("...")`: -// -// ``` -// @id("owner-override") -// permit(principal, action == Action::"read", resource) -// when { principal == resource.owner }; -// ``` -// -// Without `@id` annotations, Cedar generates `policy0`, `policy1`, … -// which is stable but meaningless. Worth documenting as best practice. - -pub mod cedar_attrs; -pub mod decision; -pub mod entities; -pub mod error; -pub mod factory; -pub mod request; -pub mod resolver; -pub mod template; - -pub use error::BuildError; -pub use factory::CedarDirectPdpFactory; -pub use resolver::CedarDirectResolver; diff --git a/builtins/pdps/cedar-direct/src/request.rs b/builtins/pdps/cedar-direct/src/request.rs deleted file mode 100644 index 1cb89124..00000000 --- a/builtins/pdps/cedar-direct/src/request.rs +++ /dev/null @@ -1,213 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/request.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Build a `cedar_policy::Request` from a `PdpCall` + `AttributeBag`. -// The resolver constructs Cedar's three required parts (principal, -// action, resource) plus the merged context, then hands them to -// Cedar's `Request::builder()`. -// -// # Principal / resource / action -// -// - **Principal:** built from the bag (see `entities::build_principal`). -// Its `EntityUid` is what we hand to `Request::principal()`. -// - **Resource:** built from `args.resource` (see `entities::build_resource`). -// - **Action:** parsed from `args.action` — must be a fully-qualified -// Cedar `EntityUid` literal like `Action::"read"` or -// `Acme::Action::"approve"`. The policy author writes this verbatim -// in their APL `cedar:(...)` step. -// -// # Context -// -// `args.context` is the operator-supplied context from the APL step. We -// merge in CPEX-provided keys at well-known paths: -// -// - `context.delegation.{chain, depth}` ← from bag's `delegation.*` -// - `context.meta.{entity_type, entity_name, scope, tags}` ← from bag's `meta.*` -// - `context.security.{labels, classification}` ← from bag's `security.*` -// -// Operators write Cedar policies against these stable paths. Any keys -// the operator put in `args.context` win over CPEX-provided defaults on -// conflict — operator intent first. -// -// # Schema -// -// When a schema is supplied, Cedar's `Context::from_json_value` validates -// the context's record shape against the action's declared context type. -// Without a schema, Cedar accepts any record. - -use apl_core::attributes::{AttributeBag, AttributeValue}; -use apl_core::step::{PdpCall, PdpError}; -use cedar_policy::{EntityUid, Schema}; -use serde_json::{json, Map, Value}; - -/// Parsed pieces of a `PdpCall` ready to feed into -/// `cedar_policy::Request::builder()`. We pull this into its own -/// struct so the resolver can sequence "build entities → build request" -/// without a giant function signature. -pub struct ParsedCall<'a> { - pub action: EntityUid, - pub context: cedar_policy::Context, - pub resource_args: &'a serde_yaml::Value, -} - -/// Parse the args + bag into the pieces a Cedar request builder needs. -/// Schema is optional; when present, the context block is validated -/// against the action's declared context shape. -pub fn parse<'a>( - call: &'a PdpCall, - bag: &AttributeBag, - schema: Option<&Schema>, -) -> Result, PdpError> { - let map = call.args.as_mapping().ok_or_else(|| { - PdpError::Dispatch( - "cedar:() args must be a mapping with `action` and `resource` keys".to_string(), - ) - })?; - - let action_str = map - .get(serde_yaml::Value::String("action".to_string())) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - PdpError::Dispatch( - "cedar:() `action` missing — provide a fully-qualified UID \ - like 'Action::\"read\"'" - .to_string(), - ) - })?; - let action: EntityUid = action_str.parse().map_err(|e| { - PdpError::Dispatch(format!( - "cedar:() `action` '{}' not a valid EntityUid: {}", - action_str, e - )) - })?; - - let resource_args = map - .get(serde_yaml::Value::String("resource".to_string())) - .ok_or_else(|| PdpError::Dispatch("cedar:() `resource` missing".to_string()))?; - - // Build the merged context: operator-supplied `args.context` keys, - // overlaid on top of CPEX-derived context (delegation, meta, - // security). On collision, the operator's value wins — they - // explicitly wrote it. - let cpex_ctx = build_cpex_context(bag); - let operator_ctx = map - .get(serde_yaml::Value::String("context".to_string())) - .cloned() - .unwrap_or(serde_yaml::Value::Null); - let mut merged = cpex_ctx; - if !operator_ctx.is_null() { - let op_json: Value = serde_json::to_value(&operator_ctx).map_err(|e| { - PdpError::Dispatch(format!("cedar:() `context` not JSON-representable: {}", e)) - })?; - merge_into(&mut merged, op_json); - } - - let cedar_context = cedar_policy::Context::from_json_value(merged, None) - .map_err(|e| PdpError::Dispatch(format!("failed to construct Cedar context: {}", e)))?; - // Note: schema-validated context construction takes an - // (action_schema, action) pair via Cedar's `from_json_value`. For - // v0 we skip schema-side validation of the context shape — the - // request builder still applies whole-request validation when a - // schema is wired into the resolver. Adding context-level schema - // validation is a polish item; doesn't change decision semantics - // when the policies are well-formed. - let _ = schema; // schema currently used at request-build time, not here - - Ok(ParsedCall { - action, - context: cedar_context, - resource_args, - }) -} - -/// Build the CPEX-provided context block (everything under -/// `context.delegation`, `context.meta`, `context.security`) from the -/// `AttributeBag`. Operators reason about these in Cedar policies via -/// the well-known paths. -fn build_cpex_context(bag: &AttributeBag) -> Value { - let mut root = Map::new(); - - let mut delegation = Map::new(); - if let Some(depth) = bag.get_int("delegation.depth") { - delegation.insert("depth".to_string(), json!(depth)); - } - // The full chain isn't currently in a flat bag key; apl-cmf - // exposes presence-only `delegated=true` plus per-attribute hops. - // When apl-cmf grows a structured `delegation.chain` shape we'll - // forward it here. For now, the depth + delegated bool let policies - // do basic chain-depth bounds checks. - if let Some(delegated) = bag.get_bool("delegated") { - delegation.insert("delegated".to_string(), json!(delegated)); - } - if !delegation.is_empty() { - root.insert("delegation".to_string(), Value::Object(delegation)); - } - - let mut meta = Map::new(); - if let Some(et) = bag.get_string("meta.entity_type") { - meta.insert("entity_type".to_string(), json!(et)); - } - if let Some(en) = bag.get_string("meta.entity_name") { - meta.insert("entity_name".to_string(), json!(en)); - } - if let Some(scope) = bag.get_string("meta.scope") { - meta.insert("scope".to_string(), json!(scope)); - } - if let Some(tags) = bag.get_string_set("meta.tags") { - meta.insert("tags".to_string(), json!(tags.iter().collect::>())); - } - if !meta.is_empty() { - root.insert("meta".to_string(), Value::Object(meta)); - } - - let mut security = Map::new(); - if let Some(labels) = bag.get_string_set("security.labels") { - security.insert( - "labels".to_string(), - json!(labels.iter().collect::>()), - ); - } - if let Some(cls) = bag.get_string("security.classification") { - security.insert("classification".to_string(), json!(cls)); - } - if !security.is_empty() { - root.insert("security".to_string(), Value::Object(security)); - } - - // Pass `authenticated` through as a top-level convenience for - // policies that want `context.authenticated` shorthand. - if let Some(auth) = bag.get_bool("authenticated") { - root.insert("authenticated".to_string(), json!(auth)); - } - - Value::Object(root) -} - -/// Shallow merge `overlay` into `target`. Operator-supplied keys win on -/// conflict at the top level; we don't try to deep-merge nested -/// records (operator says `context.meta = {custom: "x"}` and CPEX- -/// provided context.meta is fully replaced). Keeps the semantics -/// predictable. -fn merge_into(target: &mut Value, overlay: Value) { - let (Value::Object(target_map), Value::Object(overlay_map)) = (target, overlay) else { - return; - }; - for (k, v) in overlay_map { - target_map.insert(k, v); - } -} - -#[allow(dead_code)] -fn _bag_typed_value(v: &AttributeValue) -> Value { - // Reserved for future use — keeps the import alive while parts of - // the bag→JSON translation are stubbed. - match v { - AttributeValue::Bool(b) => json!(*b), - AttributeValue::Int(i) => json!(*i), - AttributeValue::Float(f) => json!(*f), - AttributeValue::String(s) => json!(s), - AttributeValue::StringSet(set) => json!(set.iter().collect::>()), - } -} diff --git a/builtins/pdps/cedar-direct/src/resolver.rs b/builtins/pdps/cedar-direct/src/resolver.rs deleted file mode 100644 index f761cacb..00000000 --- a/builtins/pdps/cedar-direct/src/resolver.rs +++ /dev/null @@ -1,314 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/resolver.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CedarDirectResolver` — the `PdpResolver` implementation. Wraps a -// loaded `PolicySet`, an `Authorizer`, and an optional `Schema`, and -// translates each APL `PdpCall` into a Cedar request → decision. -// -// # Construction surface -// -// Three constructors covering the typical sources of Cedar policy: -// -// - `from_policy_text(text)` — for inline policy in code or -// unified-config YAML. -// - `from_policy_file(path)` — for ops-managed policy files. -// - `from_config(value)` — for the unified-config block the -// `AplConfigVisitor` parses. Accepts -// either `policy_text` or -// `policy_file` (or both — policy_text -// wins). Also accepts `schema_text` / -// `schema_file` for optional schema -// loading, plus `entity_namespace` -// and `dialect`. -// -// Construction errors carry rich Cedar-specific messages via -// [`BuildError`]; the visitor wraps these into `VisitorError` → -// `PluginError::Config` at the manager boundary. - -use std::path::Path; -use std::sync::Arc; - -use async_trait::async_trait; -use cedar_policy::{Authorizer, PolicySet, Schema}; - -use apl_core::attributes::AttributeBag; -use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; - -use crate::decision::translate; -use crate::entities::build as build_entities; -use crate::error::BuildError; -use crate::request::parse as parse_call; - -/// Grow the cedar evaluation stack when the current thread has less than this -/// much headroom. cedar-policy-core's own guard is 100 KiB -/// (`REQUIRED_STACK_SPACE`); we grow at 10x that so the guard never fires -/// mid-descent. On glibc (8 MiB thread stacks) there is always more than this -/// available, so `maybe_grow` is a cheap no-op; on musl (128 KiB default) we -/// fall below it and grow onto a fresh segment. -const CEDAR_STACK_RED_ZONE: usize = 1024 * 1024; -/// Size of the fresh stack segment to run cedar on when we grow. Matches -/// glibc's default 8 MiB thread stack — the headroom cedar is validated -/// against. Allocated only on small-stack hosts, freed when evaluation returns. -const CEDAR_STACK_GROW_SIZE: usize = 8 * 1024 * 1024; - -/// PdpResolver wrapping a bare `cedar-policy` engine. Constructed from -/// policy text / file / config block at startup; evaluates each call -/// against the loaded `PolicySet`. -pub struct CedarDirectResolver { - policies: Arc, - schema: Option>, - authorizer: Authorizer, - dialect: PdpDialect, - /// Optional namespace applied to subject types: `Some("Acme")` - /// turns "User" into "Acme::User" when building the principal - /// entity. Lets schemas that namespace their entity types work - /// without policy authors having to hand-prefix every reference. - entity_namespace: Option, -} - -impl CedarDirectResolver { - /// Build a resolver from inline Cedar policy text. Use this for - /// tests, demos, and configs where the policy is small enough to - /// embed in YAML. - pub fn from_policy_text(policies: &str) -> Result { - let policy_set: PolicySet = policies - .parse() - .map_err(|e: cedar_policy::ParseErrors| BuildError::PolicyParse(e.to_string()))?; - Ok(Self { - policies: Arc::new(policy_set), - schema: None, - authorizer: Authorizer::new(), - dialect: PdpDialect::Cedar, - entity_namespace: None, - }) - } - - /// Build a resolver from a Cedar policy file on disk. Convenience - /// over `from_policy_text` for the production layout where policies - /// live in their own versioned files. - pub fn from_policy_file(path: impl AsRef) -> Result { - let path = path.as_ref(); - let text = std::fs::read_to_string(path).map_err(|source| BuildError::PolicyFile { - path: path.display().to_string(), - source, - })?; - Self::from_policy_text(&text) - } - - /// Build a resolver from a unified-config block. Shape: - /// - /// ```yaml - /// dialect: cedar # optional; default PdpDialect::Cedar - /// entity_namespace: Acme # optional; prefixes subject types - /// policy_text: | # required (or policy_file) - /// @id("owner-override") - /// permit(...); - /// policy_file: /etc/... # alternative to policy_text - /// schema_text: | # optional - /// ... - /// schema_file: /etc/... # alternative to schema_text - /// ``` - /// - /// `policy_text` wins over `policy_file` when both are present. - /// Same for `schema_text` over `schema_file`. Called by - /// `AplConfigVisitor` when it sees a Cedar PDP block in the - /// unified-config YAML. - pub fn from_config(value: &serde_yaml::Value) -> Result { - let map = value - .as_mapping() - .ok_or_else(|| BuildError::ConfigShape("Cedar PDP config must be a mapping".into()))?; - - let policy_text = read_yaml_string(map, "policy_text"); - let policy_file = read_yaml_string(map, "policy_file"); - let policies = match (policy_text, policy_file) { - (Some(text), _) => text, - (None, Some(path)) => { - std::fs::read_to_string(&path).map_err(|source| BuildError::PolicyFile { - path: path.clone(), - source, - })? - }, - (None, None) => { - return Err(BuildError::ConfigShape( - "Cedar PDP config requires `policy_text` or `policy_file`".into(), - )); - }, - }; - let policy_set: PolicySet = policies - .parse() - .map_err(|e: cedar_policy::ParseErrors| BuildError::PolicyParse(e.to_string()))?; - - let schema_text = read_yaml_string(map, "schema_text"); - let schema_file = read_yaml_string(map, "schema_file"); - let schema = match (schema_text, schema_file) { - (Some(text), _) => Some(parse_schema(&text)?), - (None, Some(path)) => { - let text = - std::fs::read_to_string(&path).map_err(|source| BuildError::SchemaFile { - path: path.clone(), - source, - })?; - Some(parse_schema(&text)?) - }, - (None, None) => None, - }; - - let dialect = match read_yaml_string(map, "dialect").as_deref() { - None | Some("cedar") => PdpDialect::Cedar, - Some(other) => PdpDialect::Custom(other.to_string()), - }; - - let entity_namespace = read_yaml_string(map, "entity_namespace"); - - Ok(Self { - policies: Arc::new(policy_set), - schema: schema.map(Arc::new), - authorizer: Authorizer::new(), - dialect, - entity_namespace, - }) - } - - /// Override the resolver's dialect. Lets operators register a Cedar - /// engine under a custom name (e.g. `PdpDialect::Custom("workload")`) - /// so they can coexist with another Cedar engine on the same - /// `PdpRouter`. - pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { - self.dialect = dialect; - self - } - - /// Attach an `entity_namespace`. Applied at request time to - /// subject types: `Some("Acme")` + bag `subject.type=User` → - /// principal UID `Acme::User::""`. - pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { - self.entity_namespace = Some(namespace.into()); - self - } - - /// Attach a schema after construction. Useful when the schema - /// comes from a separate source than the policy text. - pub fn with_schema(mut self, schema: Schema) -> Self { - self.schema = Some(Arc::new(schema)); - self - } -} - -#[async_trait] -impl PdpResolver for CedarDirectResolver { - fn dialect(&self) -> PdpDialect { - self.dialect.clone() - } - - async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result { - // Resolve `${bag-key}` placeholders in the call's args against - // the bag before any parsing. The author writes things like - // `id: ${args.repo_name}`; this pass turns them into concrete - // values so downstream entity / UID builders can stay literal. - let resolved_args = crate::template::resolve_refs(&call.args, bag)?; - let resolved_call = PdpCall { - dialect: call.dialect.clone(), - args: resolved_args, - }; - - // Everything below recurses through cedar (context/entity JSON parsing - // and policy evaluation), which self-aborts with "recursion limit - // reached" when the running thread's remaining stack drops under - // cedar's 100 KiB floor. The FFI host decides that thread's stack size, - // and musl's 128 KiB default trips the floor on inputs glibc handles - // fine. `maybe_grow` runs this block on a fresh, generously-sized stack - // segment when headroom is low (a no-op when there's already room, so - // glibc pays nothing), making cedar host-stack-agnostic. The block is - // fully synchronous — no `.await` — so it is safe to run inside the - // grown segment. See CEDAR_STACK_RED_ZONE / CEDAR_STACK_GROW_SIZE. - stacker::maybe_grow(CEDAR_STACK_RED_ZONE, CEDAR_STACK_GROW_SIZE, || { - let parsed = parse_call(&resolved_call, bag, self.schema.as_deref())?; - let entities = build_entities( - bag, - parsed.resource_args, - self.schema.as_deref(), - self.entity_namespace.as_deref(), - )?; - - let principal_uid = build_principal_uid(bag, self.entity_namespace.as_deref())?; - let resource_uid = build_resource_uid(parsed.resource_args)?; - - let request = cedar_policy::Request::new( - principal_uid, - parsed.action, - resource_uid, - parsed.context, - self.schema.as_deref(), - ) - .map_err(|e| PdpError::Dispatch(format!("Cedar request validation failed: {}", e)))?; - - let response = self - .authorizer - .is_authorized(&request, &self.policies, &entities); - - Ok(translate(&response, &self.policies)) - }) - } -} - -fn parse_schema(text: &str) -> Result { - Schema::from_cedarschema_str(text) - .map(|(schema, _warnings)| schema) - .map_err(|e| BuildError::SchemaParse(e.to_string())) -} - -fn read_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { - map.get(serde_yaml::Value::String(key.to_string()))? - .as_str() - .map(|s| s.to_string()) -} - -/// Build the principal `EntityUid` for the Cedar request. Returns the -/// SAME UID that `entities::build_principal` produces; both have to -/// agree on type + id since Cedar resolves the request's principal -/// reference into the entity set by UID equality. -fn build_principal_uid( - bag: &AttributeBag, - namespace: Option<&str>, -) -> Result { - let id = bag - .get_string("subject.id") - .ok_or_else(|| PdpError::Dispatch("bag missing `subject.id`".to_string()))?; - let kind = bag.get_string("subject.type").unwrap_or("User"); - let entity_type = match namespace { - Some(ns) if !ns.is_empty() => format!("{}::{}", ns, kind), - _ => kind.to_string(), - }; - let uid_str = format!("{}::\"{}\"", entity_type, escape_id(id)); - uid_str.parse().map_err(|e| { - PdpError::Dispatch(format!( - "failed to parse principal UID '{}': {}", - uid_str, e - )) - }) -} - -fn build_resource_uid( - resource_args: &serde_yaml::Value, -) -> Result { - let map = resource_args - .as_mapping() - .ok_or_else(|| PdpError::Dispatch("cedar:() `resource` must be a mapping".to_string()))?; - let type_name = read_yaml_string(map, "type") - .ok_or_else(|| PdpError::Dispatch("cedar:() `resource.type` missing".to_string()))?; - let id = read_yaml_string(map, "id") - .ok_or_else(|| PdpError::Dispatch("cedar:() `resource.id` missing".to_string()))?; - let uid_str = format!("{}::\"{}\"", type_name, escape_id(&id)); - uid_str.parse().map_err(|e| { - PdpError::Dispatch(format!("failed to parse resource UID '{}': {}", uid_str, e)) - }) -} - -/// Cedar identifiers in double-quoted form need backslash + quote -/// escaping. Most subject IDs are well-behaved (UUIDs, JWT sub -/// claims) — escape defensively for the cases that aren't. -fn escape_id(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") -} diff --git a/builtins/pdps/cedar-direct/src/template.rs b/builtins/pdps/cedar-direct/src/template.rs deleted file mode 100644 index b3e46074..00000000 --- a/builtins/pdps/cedar-direct/src/template.rs +++ /dev/null @@ -1,285 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/src/template.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `${bag-key}` substitution for `cedar:(...)` step args. -// -// APL authors write Cedar requests like: -// -// - cedar: -// action: 'Action::"read"' -// resource: -// type: Repo -// id: ${args.repo_name} -// attributes: -// visibility: ${args.visibility} -// owner_id: ${subject.id} -// -// This module walks the YAML and rewrites any **scalar string** equal to -// `${}` by reading the value from the `AttributeBag`. Strings -// without the `${...}` wrapper pass through unchanged, so policy authors -// can still write literals like `'Action::"read"'` or `User::"alice"` -// without surprise rewrites. -// -// # Why this looks like template substitution and not magic prefixes -// -// An earlier sketch let bare `args.X` strings substitute implicitly. -// That was load-bearing on a single hardcoded namespace and conflated -// "the author meant a placeholder" with "the author meant a string that -// happens to start with `args.`". The `${...}` form is explicit and -// generalizes to any bag key: -// -// ${subject.id} ${subject.type} -// ${role.engineer} ${perm.view_ssn} -// ${claim.email} ${args.repo_name} ${args.user.id} -// ${delegation.granted.audience} ${meta.entity_name} -// -// The vocabulary mirrors the `MessageView` projection (the bag is -// populated by apl-cmf's `extract_security` / `extract_args` from the -// same source data the view sees), so a Cedar resource template and an -// OPA `input.X` rego path can name the same attribute the same way. -// When (in a separate refactor) `AttributeBag` becomes a derived -// projection of `MessageView`, this substitution layer doesn't change — -// it's already reading the normalized vocabulary. -// -// # What gets substituted -// -// - Whole-string match: `${args.repo_name}` → value at `args.repo_name`. -// - Embedded placeholders (`prefix-${args.X}-suffix`) are NOT supported -// in v0; whole-string only. Easy to extend later, but YAGNI today — -// Cedar entity IDs / attrs almost always want the raw value. -// - Missing bag key → loud `PdpError::Dispatch`. Falling back to the -// literal would mask author bugs. -// - Mappings + sequences recurse into their members. - -use apl_core::attributes::{AttributeBag, AttributeValue}; -use apl_core::step::PdpError; - -/// Recursively walk `value`, substituting any `${}` scalar with -/// the corresponding bag value. Mappings and sequences recurse. Other -/// scalars pass through unchanged. -pub fn resolve_refs( - value: &serde_yaml::Value, - bag: &AttributeBag, -) -> Result { - match value { - serde_yaml::Value::String(s) => { - if let Some(key) = parse_placeholder(s) { - substitute(key, s, bag) - } else { - Ok(value.clone()) - } - }, - serde_yaml::Value::Mapping(map) => { - let mut out = serde_yaml::Mapping::new(); - for (k, v) in map { - out.insert(k.clone(), resolve_refs(v, bag)?); - } - Ok(serde_yaml::Value::Mapping(out)) - }, - serde_yaml::Value::Sequence(items) => { - let mut out = Vec::with_capacity(items.len()); - for item in items { - out.push(resolve_refs(item, bag)?); - } - Ok(serde_yaml::Value::Sequence(out)) - }, - _ => Ok(value.clone()), - } -} - -/// Return the inner bag key when `s` is exactly `${}` (whole-string -/// placeholder). Returns `None` for any other shape — including -/// `prefix-${args.X}` (embedded), `$args.X` (no braces), or stray `${` -/// without a matching `}`. -fn parse_placeholder(s: &str) -> Option<&str> { - let inner = s.strip_prefix("${")?.strip_suffix('}')?; - let trimmed = inner.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } -} - -fn substitute( - key: &str, - original: &str, - bag: &AttributeBag, -) -> Result { - let value = bag.get(key).ok_or_else(|| { - PdpError::Dispatch(format!( - "cedar:() references `{}` but the bag has no key `{}` — \ - check the spelling against the projection vocabulary \ - populated by apl-cmf (security / payload extractors)", - original, key - )) - })?; - - Ok(match value { - AttributeValue::String(v) => serde_yaml::Value::String(v.clone()), - AttributeValue::Bool(v) => serde_yaml::Value::Bool(*v), - AttributeValue::Int(v) => serde_yaml::Value::Number((*v).into()), - AttributeValue::Float(v) => serde_yaml::Value::Number(serde_yaml::Number::from(*v)), - AttributeValue::StringSet(set) => { - let items: Vec = set - .iter() - .map(|s| serde_yaml::Value::String(s.clone())) - .collect(); - serde_yaml::Value::Sequence(items) - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn bag_with(kvs: &[(&str, &str)]) -> AttributeBag { - let mut bag = AttributeBag::new(); - for (k, v) in kvs { - bag.set(*k, *v); - } - bag - } - - #[test] - fn substitutes_args_inside_mapping() { - let bag = bag_with(&[ - ("args.repo_name", "web-app"), - ("args.visibility", "internal"), - ]); - let yaml: serde_yaml::Value = serde_yaml::from_str( - r#" -type: Repo -id: ${args.repo_name} -attributes: - visibility: ${args.visibility} -"#, - ) - .unwrap(); - - let resolved = resolve_refs(&yaml, &bag).unwrap(); - let map = resolved.as_mapping().unwrap(); - assert_eq!( - map.get(serde_yaml::Value::String("id".into())) - .and_then(|v| v.as_str()), - Some("web-app") - ); - let attrs = map - .get(serde_yaml::Value::String("attributes".into())) - .unwrap() - .as_mapping() - .unwrap(); - assert_eq!( - attrs - .get(serde_yaml::Value::String("visibility".into())) - .and_then(|v| v.as_str()), - Some("internal") - ); - } - - #[test] - fn substitutes_across_namespaces() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - bag.set("args.repo_name", "core"); - bag.set("claim.email", "alice@corp.com"); - let yaml: serde_yaml::Value = serde_yaml::from_str( - r#" -owner: ${subject.id} -target: ${args.repo_name} -email: ${claim.email} -"#, - ) - .unwrap(); - let resolved = resolve_refs(&yaml, &bag).unwrap(); - let map = resolved.as_mapping().unwrap(); - assert_eq!( - map.get(serde_yaml::Value::String("owner".into())) - .and_then(|v| v.as_str()), - Some("alice") - ); - assert_eq!( - map.get(serde_yaml::Value::String("target".into())) - .and_then(|v| v.as_str()), - Some("core") - ); - assert_eq!( - map.get(serde_yaml::Value::String("email".into())) - .and_then(|v| v.as_str()), - Some("alice@corp.com") - ); - } - - #[test] - fn passes_through_literal_strings() { - let bag = bag_with(&[("args.x", "ignored")]); - // No `${...}` wrapper → literal. - let yaml = serde_yaml::Value::String("User::\"alice\"".into()); - let resolved = resolve_refs(&yaml, &bag).unwrap(); - assert_eq!(resolved.as_str(), Some("User::\"alice\"")); - // Even bare `args.x` is now a literal — the explicit `${...}` - // form is the only thing that triggers substitution. - let yaml = serde_yaml::Value::String("args.x".into()); - let resolved = resolve_refs(&yaml, &bag).unwrap(); - assert_eq!(resolved.as_str(), Some("args.x")); - } - - #[test] - fn missing_bag_key_errors_loudly() { - let bag = AttributeBag::new(); - let yaml = serde_yaml::Value::String("${args.missing}".into()); - let err = resolve_refs(&yaml, &bag).unwrap_err(); - let msg = format!("{:?}", err); - assert!( - msg.contains("args.missing"), - "error mentions the key: {}", - msg - ); - } - - #[test] - fn substitutes_typed_values() { - let mut bag = AttributeBag::new(); - bag.set("args.flag", true); - bag.set("args.count", 42i64); - let yaml: serde_yaml::Value = serde_yaml::from_str( - r#" -flag: ${args.flag} -count: ${args.count} -"#, - ) - .unwrap(); - let resolved = resolve_refs(&yaml, &bag).unwrap(); - let map = resolved.as_mapping().unwrap(); - assert_eq!( - map.get(serde_yaml::Value::String("flag".into())) - .and_then(|v| v.as_bool()), - Some(true) - ); - assert_eq!( - map.get(serde_yaml::Value::String("count".into())) - .and_then(|v| v.as_i64()), - Some(42) - ); - } - - #[test] - fn embedded_placeholders_not_supported_in_v0() { - let bag = bag_with(&[("args.x", "hello")]); - let yaml = serde_yaml::Value::String("prefix-${args.x}-suffix".into()); - let resolved = resolve_refs(&yaml, &bag).unwrap(); - // Whole-string only — embedded `${...}` is left alone. - assert_eq!(resolved.as_str(), Some("prefix-${args.x}-suffix")); - } - - #[test] - fn empty_placeholder_is_literal() { - let bag = AttributeBag::new(); - let yaml = serde_yaml::Value::String("${}".into()); - let resolved = resolve_refs(&yaml, &bag).unwrap(); - assert_eq!(resolved.as_str(), Some("${}")); - } -} diff --git a/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs b/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs deleted file mode 100644 index 85677dc2..00000000 --- a/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs +++ /dev/null @@ -1,229 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/tests/basic_allow_deny.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Smoke tests for `CedarDirectResolver`. Cover the canonical -// allow/deny paths, the role-driven case (proves bag attributes reach -// the principal entity), and the policy-id attribution that operators -// rely on for audit logs. - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; - -use cpex_pdp_cedar_direct::CedarDirectResolver; - -/// Build a `PdpCall` against `Action::"read"` on a `Document::"doc-1"`. -/// Used across the test cases so the request side stays constant and -/// only the policy + bag varies. -fn read_doc_call() -> PdpCall { - PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::from_str( - r#" -action: 'Action::"read"' -resource: - type: Document - id: doc-1 -"#, - ) - .unwrap(), - } -} - -fn alice_bag() -> AttributeBag { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - bag.set("subject.type", "User"); - bag -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// One unconditional `permit` policy → request → Allow. Confirms the -/// happy path end-to-end: parse, build entities, build request, -/// authorize, translate decision back. -#[tokio::test] -async fn unconditional_permit_returns_allow() { - const POLICY: &str = r#" - @id("allow-all") - permit(principal, action, resource); - "#; - - let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); - let decision = resolver - .evaluate(&read_doc_call(), &alice_bag()) - .await - .expect("evaluate"); - - assert_eq!(decision.decision, Decision::Allow); - assert_eq!(decision.diagnostics, vec!["allow-all".to_string()]); -} - -/// No policies → default-deny. Confirms the fail-closed default that -/// drops out of Cedar's semantics (no `permit` matches, so the request -/// denies). -#[tokio::test] -async fn empty_policy_set_denies_by_default() { - let resolver = CedarDirectResolver::from_policy_text("").expect("empty policy set is valid"); - let decision = resolver - .evaluate(&read_doc_call(), &alice_bag()) - .await - .expect("evaluate"); - - match decision.decision { - Decision::Deny { rule_source, .. } => { - assert_eq!(rule_source, "cedar.default_deny"); - }, - other => panic!("expected Deny on empty policy set, got {:?}", other), - } - assert!(decision.diagnostics.is_empty(), "no policies fired"); -} - -/// A policy that requires `principal.roles.contains("hr")`. Bag has -/// `role.hr=true` → reaches principal.roles → Allow. Proves the -/// bag-attribute-to-entity-attribute translation works end-to-end: -/// apl-cmf would normally populate `role.hr` from -/// `SecurityExtension.subject.roles`, but the bag works the same way -/// however it got there. -#[tokio::test] -async fn role_in_bag_reaches_principal_attributes() { - const POLICY: &str = r#" - @id("hr-only") - permit(principal, action == Action::"read", resource) - when { principal.roles.contains("hr") }; - "#; - - let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); - - // Alice has role.hr → policy permits. - let mut bag = alice_bag(); - bag.set("role.hr", true); - let decision = resolver - .evaluate(&read_doc_call(), &bag) - .await - .expect("evaluate"); - assert_eq!(decision.decision, Decision::Allow); - assert_eq!(decision.diagnostics, vec!["hr-only".to_string()]); - - // Bob has no roles → policy doesn't match → default-deny. - let mut bob_bag = AttributeBag::new(); - bob_bag.set("subject.id", "bob"); - bob_bag.set("subject.type", "User"); - let decision = resolver - .evaluate(&read_doc_call(), &bob_bag) - .await - .expect("evaluate"); - match decision.decision { - Decision::Deny { rule_source, .. } => { - assert_eq!( - rule_source, "cedar.default_deny", - "no permit matched → default-deny, not policy-attributed" - ); - }, - other => panic!("expected Deny for bob, got {:?}", other), - } -} - -/// A policy with `@id("blocklist")` that forbids access for a specific -/// principal. When the forbid fires, the violation's `rule_source` -/// should carry the policy id so wire errors / audit logs say -/// "denied via blocklist" instead of "denied by Cedar." -#[tokio::test] -async fn forbid_attribution_carries_policy_id() { - const POLICY: &str = r#" - @id("permit-all") - permit(principal, action, resource); - - @id("blocklist") - forbid(principal == User::"alice", action, resource); - "#; - - let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); - let decision = resolver - .evaluate(&read_doc_call(), &alice_bag()) - .await - .expect("evaluate"); - - match decision.decision { - Decision::Deny { - rule_source, - reason, - } => { - assert_eq!( - rule_source, "blocklist", - "violation should be attributed to the forbid policy by id" - ); - assert!( - reason.as_deref().unwrap_or("").contains("blocklist"), - "reason should mention the firing policy: {:?}", - reason - ); - }, - other => panic!("expected Deny via blocklist, got {:?}", other), - } - assert!(decision.diagnostics.iter().any(|d| d == "blocklist")); -} - -/// Missing `subject.id` in the bag is a configuration fault (identity -/// hook didn't populate it). Resolver returns a Dispatch error rather -/// than silently building a malformed Cedar request. -#[tokio::test] -async fn missing_subject_id_errors_clearly() { - const POLICY: &str = "permit(principal, action, resource);"; - let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); - - // Empty bag → no subject.id. - let bag = AttributeBag::new(); - let err = resolver - .evaluate(&read_doc_call(), &bag) - .await - .expect_err("should fail with no subject.id"); - - let msg = format!("{}", err); - assert!( - msg.contains("subject.id"), - "error should mention the missing key: {}", - msg - ); -} - -/// Construction from a config block — the path the visitor uses when -/// it sees a Cedar PDP block in unified-config YAML. -#[tokio::test] -async fn from_config_builds_resolver_from_yaml_block() { - let yaml: serde_yaml::Value = serde_yaml::from_str( - r#" -dialect: cedar -policy_text: | - @id("from-config") - permit(principal, action, resource); -"#, - ) - .expect("yaml parses"); - - let resolver = CedarDirectResolver::from_config(&yaml).expect("config valid"); - let decision = resolver - .evaluate(&read_doc_call(), &alice_bag()) - .await - .expect("evaluate"); - assert_eq!(decision.decision, Decision::Allow); - assert_eq!(decision.diagnostics, vec!["from-config".to_string()]); -} - -/// Operators can register the resolver under a custom dialect to -/// coexist with another Cedar engine on the same PdpRouter. -#[tokio::test] -async fn with_dialect_overrides_default() { - let resolver = CedarDirectResolver::from_policy_text("permit(principal, action, resource);") - .expect("policy parses") - .with_dialect(PdpDialect::Custom("workload".to_string())); - - assert_eq!( - resolver.dialect(), - PdpDialect::Custom("workload".to_string()) - ); -} diff --git a/builtins/pdps/cedar-direct/tests/small_stack_eval.rs b/builtins/pdps/cedar-direct/tests/small_stack_eval.rs deleted file mode 100644 index aabd1ce2..00000000 --- a/builtins/pdps/cedar-direct/tests/small_stack_eval.rs +++ /dev/null @@ -1,72 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/tests/small_stack_eval.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Regression test for the musl small-stack / cedar "recursion limit" trap. -// -// cedar-policy-core guards evaluation against stack exhaustion by checking -// `stacker::remaining_stack()` against a 100 KiB floor (REQUIRED_STACK_SPACE). -// An FFI host chooses the thread stack the evaluation runs on: glibc defaults -// threads to 8 MiB (clears the floor), but musl defaults to 128 KiB, so once -// the call chain has descended into evaluation the floor trips and cedar -// returns "recursion limit reached" on inputs that decide fine on glibc. -// -// `CedarDirectResolver::evaluate` wraps the cedar work in `stacker::maybe_grow`, -// which runs it on a fresh, generously-sized segment when the current stack is -// low — making cedar host-stack-agnostic. This test pins that behavior by -// evaluating on a 128 KiB OS thread (musl's default): -// -// * with the guard -> grows onto a large segment -> Allow -// * without it -> cedar trips its floor -> Err("recursion limit reached") -// -// We use `futures::executor::block_on` rather than tokio: `evaluate` has no -// real await points (cedar is synchronous), and the lighter executor keeps the -// pre-`maybe_grow` footprint small so the 128 KiB proves the grow path, not the -// runtime. - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; -use cpex_pdp_cedar_direct::CedarDirectResolver; - -/// musl's default thread stack size — below cedar's 100 KiB remaining-stack -/// floor once evaluation is underway. -const MUSL_DEFAULT_STACK: usize = 128 * 1024; - -#[test] -fn evaluate_succeeds_on_musl_sized_thread_stack() { - let decision = std::thread::Builder::new() - .name("musl-stack-sim".into()) - .stack_size(MUSL_DEFAULT_STACK) - .spawn(|| { - const POLICY: &str = r#" - @id("allow-all") - permit(principal, action, resource); - "#; - let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); - - let call = PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::from_str( - "action: 'Action::\"read\"'\nresource:\n type: Document\n id: doc-1\n", - ) - .expect("call args parse"), - }; - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - bag.set("subject.type", "User"); - - futures::executor::block_on(resolver.evaluate(&call, &bag)) - }) - .expect("spawn 128 KiB thread") - .join() - .expect("evaluation thread must not overflow/panic") - .expect("cedar must evaluate on a musl-sized stack (maybe_grow guard)"); - - assert_eq!( - decision.decision, - Decision::Allow, - "an unconditional permit must Allow even on a 128 KiB thread stack", - ); -} diff --git a/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs b/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs deleted file mode 100644 index 09828aa5..00000000 --- a/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs +++ /dev/null @@ -1,167 +0,0 @@ -// Location: ./builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end integration: a unified-config YAML that -// -// 1. declares a `cedar-direct` PDP under `global.apl.pdp[]`, -// 2. embeds Cedar policy text inline in that declaration, -// 3. attaches a `cedar:(...)` policy step to a route, -// -// must flow a real authorization decision from the cpex-core dispatcher -// through `AplConfigVisitor` → `PdpFactory` → `CedarDirectResolver` → -// Cedar's `Authorizer` → back into the route handler's deny/allow split. -// -// This proves the *wiring* end-to-end. The cedar-direct unit tests in -// `basic_allow_deny.rs` already cover the resolver in isolation; what's -// special here is that the resolver was never instantiated in Rust by -// the test — the visitor built it from YAML at `load_config_yaml` time -// because the host registered `CedarDirectPdpFactory` via -// `AplOptions.pdp_factories`. If this test passes, an operator who -// drops a `cedar-direct` block into their config gets the same behavior -// without writing any glue. - -use std::collections::HashSet; -use std::sync::Arc; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension, SubjectType}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; -use cpex_pdp_cedar_direct::CedarDirectPdpFactory; - -// The configuration the visitor walks. Single Cedar permit policy that -// only fires for principals carrying the `reader` role; everything else -// hits Cedar's default-deny path. -const YAML: &str = r#" -global: - apl: - pdp: - - kind: cedar-direct - policy_text: | - @id("reader-permit") - permit(principal, action == Action::"read", resource) - when { principal.roles.contains("reader") }; -routes: - - tool: get_document - apl: - pre_invocation: - - cedar: - action: 'Action::"read"' - resource: - type: Document - id: doc-42 -"#; - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut m = MetaExtension::default(); - m.entity_type = Some("tool".to_string()); - m.entity_name = Some(name.to_string()); - m -} - -/// Build a `SecurityExtension` with the given subject id and roles. The -/// bag-builder lifts these into `subject.id` / `role.` keys, which -/// `entities.rs` reads when constructing the Cedar principal. Anything -/// the policy needs about the principal must come through this surface. -fn security_with_roles(id: &str, roles: &[&str]) -> SecurityExtension { - SecurityExtension { - subject: Some(SubjectExtension { - id: Some(id.to_string()), - subject_type: Some(SubjectType::User), - roles: roles.iter().map(|r| r.to_string()).collect::>(), - ..Default::default() - }), - ..Default::default() - } -} - -async fn build_manager() -> Arc { - let mgr = Arc::new(PluginManager::default()); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - // The factory is the load-bearing wiring under test: the - // visitor sees `kind: cedar-direct` in YAML and finds this - // factory by key. - pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -fn payload() -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, "fetch doc-42"), - } -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Principal `alice` carries `role.reader=true`, which the permit policy -/// requires. End-to-end: visitor built the resolver from YAML, route -/// handler dispatched the `cedar:` step into that resolver, Cedar -/// returned Allow, the pipeline continues. -#[tokio::test] -async fn config_declared_cedar_pdp_allows_reader() { - let mgr = build_manager().await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - result.continue_processing, - "reader-permit should allow alice; got violation = {:?}", - result.violation - ); -} - -/// Principal `bob` carries no roles, so the permit's guard -/// (`principal.roles.contains("reader")`) is false and no other policy -/// fires. Cedar default-denies; the route handler maps that to a -/// pipeline-halting violation with `code = cedar.default_deny`. -#[tokio::test] -async fn config_declared_cedar_pdp_denies_non_reader() { - let mgr = build_manager().await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("bob", &[]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "missing reader role should default-deny", - ); - let v = result - .violation - .expect("deny path must surface a violation"); - assert_eq!( - v.code, "cedar.default_deny", - "default-deny path should use the cedar-direct sentinel code; got {}", - v.code - ); -} diff --git a/builtins/pdps/cel/Cargo.toml b/builtins/pdps/cel/Cargo.toml deleted file mode 100644 index bc0e3656..00000000 --- a/builtins/pdps/cel/Cargo.toml +++ /dev/null @@ -1,64 +0,0 @@ -# Location: ./builtins/pdps/cel/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-pdp-cel — a `PdpResolver` that evaluates CEL (Common Expression -# Language) boolean predicates against the policy `AttributeBag`, authored -# inline in route YAML (`cel: { expr: "..." }`). -# -# See the crate-level module docs in `src/lib.rs` for the full picture: -# where it sits in the stack, the bag→CEL activation, the decision -# contract, when to choose CEL vs Cedar/OPA, and why evaluation is -# synchronous and side-effect-free. - -[package] -name = "cpex-pdp-cel" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX PDP — CEL (Common Expression Language) predicate evaluation." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -apl-core = { workspace = true } -# The CEL interpreter from cel-rust/cel-rust (formerly -# clarkmcc/cel-rust). Sync eval, comprehension macros (`has`, `all`, -# `exists`, `map`, `filter`), custom functions. Caret spec tracks 0.x -# patch/minor; pin tighter if the activation API churns. -# -# Features pinned explicitly so a future change to the upstream -# `default = [...]` set can't silently add or remove capabilities -# operator policies depend on: -# - regex: enables `matches(s, pattern)` for URL/path predicates -# ("did the request target match `^/api/v1/`?"); virtually every -# real policy needs this. -# - chrono: enables `timestamp()`, `duration()`, and date/time -# arithmetic for time-window policies ("business hours", "this -# 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.14", default-features = false, features = ["regex", "chrono"] } -async-trait = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } - -[dev-dependencies] -# End-to-end integration tests wire the cel factory through the apl-cpex -# visitor and exercise it against a real `PluginManager`. These dev-dep -# edges only exist for tests — the crate itself stays apl-core-only at -# compile time so it can be used standalone (e.g. in a custom orchestrator -# that doesn't go through apl-cpex at all). -apl-cmf = { workspace = true } -apl-cpex = { workspace = true } -cpex-core = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } - -[lints] -workspace = true diff --git a/builtins/pdps/cel/src/activation.rs b/builtins/pdps/cel/src/activation.rs deleted file mode 100644 index 87cc9785..00000000 --- a/builtins/pdps/cel/src/activation.rs +++ /dev/null @@ -1,375 +0,0 @@ -// Location: ./builtins/pdps/cel/src/activation.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Bag → CEL activation mapping. -// -// APL's `AttributeBag` is a flat `HashMap` with -// dotted keys (`subject.id`, `role.hr`, `delegation.depth`). CEL wants -// nested structures so `subject.id` reads as field selection on a -// `subject` map. This module rebuilds the flat bag into a tree of CEL -// maps and registers each top-level namespace as a CEL variable. -// -// Type mapping (`AttributeValue` → `cel::Value`): -// Bool → Value::Bool -// Int → Value::Int -// Float → Value::Float -// String → Value::String -// StringSet → Value::List(of String) (so `"x" in session.labels` works) -// -// Collision rule: if a key is both a leaf and a namespace prefix -// (`delegation` AND `delegation.depth`), the namespace (map) wins and the -// scalar leaf is dropped with a `tracing::warn!`. In practice the cmf -// BagBuilder never emits both, but the bag is an open namespace so we -// resolve it deterministically rather than panic. - -use std::collections::{BTreeMap, HashMap}; - -use apl_core::attributes::{AttributeBag, AttributeValue}; -use cel::{Context, Value}; - -/// Build a CEL evaluation context from the policy bag plus the `cel:` -/// step's extra args. -/// -/// - Every dotted bag key becomes nested CEL maps; each top-level segment -/// (`subject`, `role`, `delegation`, `session`, `args`, …) is registered -/// as a CEL variable. -/// - Each top-level key of `extra_args` (everything the author put under -/// `cel:` besides `expr`) is registered as an additional variable — -/// e.g. `resource`, `context` — mirroring how `cedar:` surfaces them. -/// - On a name collision between an `extra_args` key and a bag namespace, -/// the **bag wins** (the bag is the authoritative, framework-populated -/// vocabulary; args can't shadow it by accident). -/// -/// The returned context also carries CEL's standard function/macro library -/// (via `Context::default`), so `has()`, `size()`, `all()`, `exists()`, -/// `map()`, `filter()`, string methods, etc. are all available. -pub fn bag_to_context(bag: &AttributeBag, extra_args: &serde_yaml::Value) -> Context<'static> { - let mut ctx = Context::default(); - - // 1. Author-supplied extra args first (so the bag overrides on - // collision). Skip `expr` — that's the program text, not a - // variable. - let mut extra_names: std::collections::HashSet = std::collections::HashSet::new(); - if let Some(map) = extra_args.as_mapping() { - for (k, v) in map { - let Some(name) = k.as_str() else { continue }; - if name == "expr" { - continue; - } - extra_names.insert(name.to_string()); - ctx.add_variable_from_value(name.to_string(), yaml_to_value(v)); - } - } - - // 2. The bag namespaces (authoritative). Build the tree, then register - // each top-level node as a variable. Log when a bag namespace - // shadows an author-supplied extra arg with the same name — the - // bag wins by design, but a silent shadow can mask a typo in the - // author's args block. - let root = build_tree(bag); - for (name, node) in root { - if extra_names.contains(&name) { - tracing::debug!( - name = %name, - "CEL activation: bag namespace shadows an extra-arg of the same name; \ - bag value wins by design", - ); - } - ctx.add_variable_from_value(name, node_to_value(node)); - } - - ctx -} - -/// Internal tree node: either a leaf scalar/list or a nested namespace. -enum Node { - Leaf(Value), - Branch(BTreeMap), -} - -/// Build the top-level namespace tree from the flat, dotted bag. -fn build_tree(bag: &AttributeBag) -> BTreeMap { - let mut root: BTreeMap = BTreeMap::new(); - for (key, value) in bag.iter() { - let segments: Vec<&str> = key.split('.').collect(); - insert(&mut root, key, &segments, attr_to_value(value)); - } - root -} - -/// Insert a leaf at the dotted path, creating intermediate branches. -/// Namespace-wins on leaf/branch collisions (see module docs). -fn insert(level: &mut BTreeMap, full_key: &str, segments: &[&str], leaf: Value) { - // `bag.iter()` never yields empty keys today, but iterator - // contracts can drift — return cleanly rather than panic if a - // future bag implementation emits one. The caller's leaf is just - // dropped; no name to insert under. - let Some((head, rest)) = segments.split_first() else { - return; - }; - let head = (*head).to_string(); - - if rest.is_empty() { - // Terminal segment — place the leaf, unless a namespace already - // claimed this name (namespace wins). - match level.get(&head) { - Some(Node::Branch(_)) => { - tracing::warn!( - key = %full_key, - "CEL activation: scalar key collides with an existing namespace; \ - keeping the namespace and dropping the scalar" - ); - }, - _ => { - level.insert(head, Node::Leaf(leaf)); - }, - } - return; - } - - // Intermediate segment — descend, converting a leaf into a branch if - // needed (namespace wins). - let entry = level - .entry(head) - .or_insert_with(|| Node::Branch(BTreeMap::new())); - if let Node::Leaf(_) = entry { - tracing::warn!( - key = %full_key, - "CEL activation: namespace prefix collides with an existing scalar; \ - promoting to a namespace and dropping the scalar" - ); - *entry = Node::Branch(BTreeMap::new()); - } - if let Node::Branch(child) = entry { - insert(child, full_key, rest, leaf); - } -} - -/// Recursively convert a tree node into a `cel::Value`. -fn node_to_value(node: Node) -> Value { - match node { - Node::Leaf(v) => v, - Node::Branch(children) => { - let map: HashMap = children - .into_iter() - .map(|(k, child)| (k, node_to_value(child))) - .collect(); - Value::from(map) - }, - } -} - -/// Convert one `AttributeValue` to a `cel::Value`. -/// -/// CEL's type model distinguishes `int` and `double` strictly: -/// `delegation.depth <= 2` errors if `delegation.depth` is a double -/// and `2` is an int (the literal). To shield authors from that -/// asymmetry, an `f64` whose value is a whole number and fits a `i64` -/// is yielded as `Value::Int`. The same logic applies to the -/// author-supplied yaml args (see `yaml_to_value`) — both surfaces -/// now agree. -fn attr_to_value(attr: &AttributeValue) -> Value { - match attr { - AttributeValue::Bool(b) => Value::from(*b), - AttributeValue::Int(i) => Value::from(*i), - AttributeValue::Float(f) => float_to_value(*f), - AttributeValue::String(s) => Value::from(s.clone()), - // StringSet → list(string). Sort before yielding so authors - // who reach for `session.labels[0]` (or any other - // index-dependent operation) get a stable answer across runs - // and rust releases. `in` / `exists` / `all` / `filter` don't - // care about order, but determinism by construction beats - // "works on my machine" when the policy ever indexes. - AttributeValue::StringSet(set) => { - let mut sorted: Vec<&String> = set.iter().collect(); - sorted.sort(); - let items: Vec = sorted.into_iter().map(|s| Value::from(s.clone())).collect(); - Value::from(items) - }, - } -} - -/// Yield an `f64` as `Value::Int` when it represents a whole number -/// in `i64` range, otherwise `Value::Float`. Used by both -/// `attr_to_value` (bag scalars) and `yaml_to_value` (author args) so -/// `delegation.depth: 2` works against the literal `2` regardless of -/// whether the bag populated it as `Int(2)` or `Float(2.0)`. -fn float_to_value(f: f64) -> Value { - if f.is_finite() && f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 { - Value::from(f as i64) - } else { - Value::from(f) - } -} - -/// Convert a `serde_yaml::Value` (author-supplied `cel:` args) to a -/// `cel::Value`. Numbers without a fractional part map to `Int`, otherwise -/// `Float`. Non-string mapping keys are skipped (CEL map keys here are -/// always strings for author ergonomics). -fn yaml_to_value(v: &serde_yaml::Value) -> Value { - match v { - serde_yaml::Value::Null => Value::Null, - serde_yaml::Value::Bool(b) => Value::from(*b), - serde_yaml::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::from(i) - } else { - float_to_value(n.as_f64().unwrap_or(f64::NAN)) - } - }, - serde_yaml::Value::String(s) => Value::from(s.clone()), - serde_yaml::Value::Sequence(seq) => { - let items: Vec = seq.iter().map(yaml_to_value).collect(); - Value::from(items) - }, - serde_yaml::Value::Mapping(map) => { - let mut out: HashMap = HashMap::new(); - for (k, val) in map { - if let Some(name) = k.as_str() { - out.insert(name.to_string(), yaml_to_value(val)); - } - } - Value::from(out) - }, - // serde_yaml's tagged values are not used in APL configs; treat as null. - _ => Value::Null, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - - fn run_cel(expr: &str, ctx: &Context<'static>) -> Result { - let program = cel::Program::compile(expr).map_err(|e| e.to_string())?; - program.execute(ctx).map_err(|e| e.to_string()) - } - - fn truthy(expr: &str, bag: &AttributeBag) -> bool { - let ctx = bag_to_context(bag, &serde_yaml::Value::Null); - matches!(run_cel(expr, &ctx), Ok(Value::Bool(true))) - } - - #[test] - fn dotted_keys_become_nested_maps() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - bag.set("subject.type", "user"); - assert!(truthy("subject.id == 'alice'", &bag)); - assert!(truthy("subject.type == 'user'", &bag)); - } - - #[test] - fn bool_int_float_scalars() { - let mut bag = AttributeBag::new(); - bag.set("role.hr", true); - bag.set("delegation.depth", 2_i64); - bag.set("intent.confidence", 0.92_f64); - assert!(truthy("role.hr", &bag)); - assert!(truthy("delegation.depth <= 2", &bag)); - assert!(truthy("intent.confidence > 0.9", &bag)); - } - - #[test] - fn single_segment_key_is_top_level_variable() { - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - assert!(truthy("authenticated", &bag)); - } - - #[test] - fn string_set_becomes_list_for_in_operator() { - let mut bag = AttributeBag::new(); - bag.set( - "session.labels", - HashSet::from(["PII".to_string(), "compensation".to_string()]), - ); - assert!(truthy("'PII' in session.labels", &bag)); - assert!(truthy("'compensation' in session.labels", &bag)); - assert!(truthy("!('PHI' in session.labels)", &bag)); - // Comprehension macros work over the list too. - assert!(truthy("session.labels.exists(l, l == 'PII')", &bag)); - } - - /// An `f64` whose value is a whole number is yielded as an int so - /// authors can compare against integer literals without CEL's - /// strict int-vs-double type rules blowing up. A genuinely - /// fractional `f64` still arrives as a float (so `confidence > 0.9` - /// behaves correctly). - #[test] - fn whole_number_float_arrives_as_int_for_literal_compare() { - let mut bag = AttributeBag::new(); - bag.set("delegation.depth", 2.0_f64); - bag.set("intent.confidence", 0.92_f64); - // Compare-with-int-literal: requires the bag value to be int. - assert!(truthy("delegation.depth == 2", &bag)); - assert!(truthy("delegation.depth <= 2", &bag)); - // Genuine doubles still compare to double literals. - assert!(truthy("intent.confidence > 0.9", &bag)); - } - - /// `StringSet` is yielded in sorted order so indexing returns a - /// stable value across runs. `"compensation" < "PII"` (ASCII; - /// uppercase letters sort before lowercase, but both labels here - /// are different cases so ordering is alphanumeric on the first - /// char). Pinning the order keeps an author who reaches for - /// `session.labels[0]` from getting different answers between - /// builds. - #[test] - fn string_set_yields_sorted_order_for_stable_indexing() { - let mut bag = AttributeBag::new(); - bag.set( - "session.labels", - HashSet::from(["zeta".to_string(), "alpha".to_string(), "mu".to_string()]), - ); - assert!(truthy("session.labels[0] == 'alpha'", &bag)); - assert!(truthy("session.labels[1] == 'mu'", &bag)); - assert!(truthy("session.labels[2] == 'zeta'", &bag)); - } - - #[test] - fn has_macro_guards_optional_fields() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - // `subject` exists but has no `email` field → has() is false. - assert!(truthy("has(subject.id) && !has(subject.email)", &bag)); - } - - #[test] - fn extra_args_surface_as_variables_bag_wins_on_collision() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - let args = serde_yaml::from_str::( - "resource:\n kind: document\n sensitivity: 3\nsubject: shadowed\n", - ) - .unwrap(); - let ctx = bag_to_context(&bag, &args); - // Author-supplied `resource` is visible. - assert!(matches!( - run_cel( - "resource.kind == 'document' && resource.sensitivity == 3", - &ctx - ), - Ok(Value::Bool(true)) - )); - // `subject` from the bag wins over the args' `subject: shadowed`. - assert!(matches!( - run_cel("subject.id == 'alice'", &ctx), - Ok(Value::Bool(true)) - )); - } - - #[test] - fn namespace_wins_on_leaf_collision() { - // Both `delegation` (scalar) and `delegation.depth` (under a - // namespace) present — the namespace must win so `delegation.depth` - // resolves rather than erroring on a scalar field access. - let mut bag = AttributeBag::new(); - bag.set("delegation", "scalar-value"); - bag.set("delegation.depth", 3_i64); - assert!(truthy("delegation.depth == 3", &bag)); - } -} diff --git a/builtins/pdps/cel/src/error.rs b/builtins/pdps/cel/src/error.rs deleted file mode 100644 index 844905cb..00000000 --- a/builtins/pdps/cel/src/error.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Location: ./builtins/pdps/cel/src/error.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Build-time errors for `CelResolver`. These fire at construction -// (parsing the unified-config block); never at request time. -// -// Request-time problems (a bad `expr`, an undeclared variable, a -// non-boolean result) flow through `apl_core::PdpError` / a fail-closed -// `PdpDecision::Deny` because that's the trait's return surface — -// deliberately separate from build errors, which are config faults the -// operator fixes once. -// -// `BuildError` implements `std::error::Error` (via thiserror), so it -// boxes cleanly into `apl_cpex::visitor::VisitorError` when the -// AplConfigVisitor builds a resolver from a unified-config block. The -// visitor wraps that into `cpex_core::PluginError::Config` on its way out -// of `load_config_yaml`. - -use thiserror::Error; - -/// Error returned at resolver construction (`CelResolver::from_config`). -#[derive(Debug, Error)] -pub enum BuildError { - /// Config block wasn't a mapping, or a field had the wrong shape / - /// an unrecognized value (e.g. `on_error: maybe`). - #[error("invalid CEL PDP config: {0}")] - ConfigShape(String), -} diff --git a/builtins/pdps/cel/src/factory.rs b/builtins/pdps/cel/src/factory.rs deleted file mode 100644 index acb605ed..00000000 --- a/builtins/pdps/cel/src/factory.rs +++ /dev/null @@ -1,50 +0,0 @@ -// Location: ./builtins/pdps/cel/src/factory.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CelPdpFactory` — the `PdpFactory` implementation that lets the apl-cpex -// visitor instantiate `CelResolver` from a unified-config YAML block: -// -// ```yaml -// global: -// pdp: -// - kind: cel -// on_error: deny # optional; deny | allow, default deny -// ``` -// -// The CEL expression itself lives in each route's `cel: { expr: "..." }` -// step, not in this block — so the global config usually just declares the -// resolver exists. Hosts register an instance of this factory in -// `AplOptions.pdp_factories`; the visitor matches it to the block by `kind`. - -use std::sync::Arc; - -use apl_core::step::{PdpFactory, PdpResolver}; - -use crate::resolver::CelResolver; - -/// Factory for `CelResolver`. Reports `kind() = "cel"`; builds resolvers -/// from the unified-config block via [`CelResolver::from_config`]. -#[derive(Default)] -pub struct CelPdpFactory; - -impl CelPdpFactory { - pub fn new() -> Self { - Self - } -} - -impl PdpFactory for CelPdpFactory { - fn kind(&self) -> &str { - "cel" - } - - fn build( - &self, - config: &serde_yaml::Value, - ) -> Result, Box> { - let resolver = CelResolver::from_config(config)?; - Ok(Arc::new(resolver)) - } -} diff --git a/builtins/pdps/cel/src/lib.rs b/builtins/pdps/cel/src/lib.rs deleted file mode 100644 index 46113c91..00000000 --- a/builtins/pdps/cel/src/lib.rs +++ /dev/null @@ -1,126 +0,0 @@ -// Location: ./builtins/pdps/cel/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-pdp-cel — `PdpResolver` over the `cel` (Common Expression Language) -// interpreter. -// -// # Where this lives in the stack -// -// APL evaluator (apl-core) -// │ `cel: { expr: "..." }` step -// ▼ -// PdpRouter (apl-cpex) — dispatches by dialect (PdpDialect::Cel) -// │ resolver.evaluate(call, bag) -// ▼ -// CelResolver — THIS CRATE -// │ bag → CEL activation, compile-once / eval-many -// ▼ -// cel::Program::execute — clarkmcc's CEL interpreter -// -// # Inputs (`PdpCall.args`) -// -// APL routes call CEL like: -// -// ```yaml -// policy: -// - cel: -// expr: | -// subject.id in ["alice", "bob"] -// && delegation.depth <= 2 -// && !("compensation" in session.labels) -// on_deny: -// - deny("cel policy denied access") -// on_allow: -// - taint(audit_pass, session) -// ``` -// -// Required key: `expr` (a string). Any other keys in `args` (e.g. -// `resource`, `context`) are surfaced to the expression as additional -// top-level CEL variables, mirroring how `cedar:` exposes resource/context. -// -// The canonical step form is the block-map shown above (`cel: { expr: -// "..." }`); it's what the integration tests and most policies use. The -// parser also accepts the call form `cel:(expr: "...")` — both compile to -// the same `PdpCall`. Prefer the map form in new policy: it reads cleanly -// when the `expr` spans multiple lines. -// -// # The attribute vocabulary (bag → CEL activation) -// -// APL's `AttributeBag` is a flat namespace of dotted keys -// (`subject.id`, `role.hr`, `delegation.depth`, `session.labels`). The -// resolver rebuilds those into nested CEL maps so authors write natural -// field selection: -// -// - `subject.id` → string `subject.id == "alice"` -// - `role.hr` (=true) → bool `role.hr` -// - `delegation.depth` → int `delegation.depth <= 2` -// - `session.labels` → list(string) `"PII" in session.labels` -// - `intent.confidence` → double `intent.confidence > 0.9` -// -// See `activation::bag_to_context` for the exact mapping and the -// leaf-vs-namespace collision rule. -// -// # Decision contract -// -// The expression MUST evaluate to a boolean. `true → Allow`, -// `false → Deny`. A non-boolean result, an undeclared-variable reference, -// a compile error, or any other evaluation error is **fail-closed → Deny** -// with the cause in `PdpDecision.diagnostics` (matches APL's PDP -// fail-closed default). Operators can flip a *runtime* error -// (undeclared variable, type error, non-boolean) to allow-through via -// `on_error: allow` in the PDP config block, but the default is `deny`. -// Compile errors are never flippable — see `resolver::OnError`. -// -// "Non-boolean" means the expression's top-level value is anything other -// than `true`/`false`. Common author mistakes: -// -// - `subject.id` → a string → degenerate → Deny -// - `delegation.depth` → an int → degenerate → Deny -// - `subject.roles` → a list → degenerate → Deny -// - `has(session.token) ? 1 : 0` → an int → degenerate → Deny -// -// Note CEL `null` is its own value, distinct from `false`: an expression -// that yields `null` (e.g. an optional field selected without a guard) is -// non-boolean and therefore Deny under the default — it is NOT treated as -// a `false` policy decision. Guard optional fields with `has(...)` and -// compare explicitly (`has(role.reader) && role.reader`) so the result is -// always a real boolean. -// -// # CEL vs Cedar (which backend?) -// -// Reach for **cel** when the decision is a self-contained boolean -// predicate over the common attribute vocabulary, authored inline in the -// route YAML, with no external policy store — relevance / consistency / -// lightweight ABAC. Reach for **cedar / opa** when policy -// lives outside the route (versioned/signed policy sets, central -// management) or needs the full entity/relationship model. CEL trades -// Cedar's policy-set machinery for zero-glue, in-line expressiveness. -// -// # Synchronous by design -// -// CEL evaluation here is synchronous and side-effect-free — no network, -// no I/O, no async. That's deliberate: attribute resolution and any -// side-effecting work (remote lookups, credential exchange) belong in APL -// plugin steps that populate the bag *before* the `cel:` step runs. There -// is no async-CEL path, and custom functions registered via -// `CelResolver::with_functions` should likewise stay pure and fast — they -// run inline on every evaluation while the activation context is held. -// -// # Compile cache -// -// Each distinct `expr` string compiles to a `cel::Program` exactly once; -// the resolver caches programs keyed by source string and reuses them on -// every subsequent call. Because APL compiles route YAML once at config -// load, a given route's `cel:` expression compiles a single time over the -// process lifetime. - -pub mod activation; -pub mod error; -pub mod factory; -pub mod resolver; - -pub use error::BuildError; -pub use factory::CelPdpFactory; -pub use resolver::{CelResolver, OnError}; diff --git a/builtins/pdps/cel/src/resolver.rs b/builtins/pdps/cel/src/resolver.rs deleted file mode 100644 index 816cd2bb..00000000 --- a/builtins/pdps/cel/src/resolver.rs +++ /dev/null @@ -1,896 +0,0 @@ -// Location: ./builtins/pdps/cel/src/resolver.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CelResolver` — the `PdpResolver` implementation. Compiles each distinct -// `cel: { expr: "..." }` expression once (cached by source string) and -// evaluates it against the policy `AttributeBag` on every call. -// -// # Decision contract -// -// - expression → `true` → Allow -// - expression → `false` → Deny (a legitimate policy denial; always honored) -// - non-boolean result, undeclared-variable reference, or any other -// evaluation error → governed by `on_error` (default `Deny`, i.e. -// fail-closed). `on_error: allow` flips these degenerate cases to Allow. -// - a `cel:` step with no `expr` string is a config bug → `PdpError`. -// -// The cause of any Deny / error is recorded in `PdpDecision.diagnostics` -// for audit, and is the `rule_source` on the resulting Deny. - -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -use async_trait::async_trait; -use cel::{Context, Program, Value}; - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; - -use crate::activation::bag_to_context; -use crate::error::BuildError; - -/// What to do when an expression errors at runtime (an undeclared -/// variable, a type error, a custom-function panic) or returns a -/// non-boolean value. A `false` result is never affected — it is -/// always a Deny. -/// -/// **Compile errors are NOT governed by this enum.** A compile error -/// means an author wrote malformed CEL; there's no legitimate reason -/// to flip that to Allow, so it ALWAYS resolves to Deny + a loud -/// `tracing::error!`. If you flipped a compile error to Allow you'd -/// be silently turning malformed policy into "always allow" — which -/// is a security-hostile default we deliberately don't expose. -/// Cache-full rejections (the cap was hit) are treated as eval errors -/// — they're a runtime resource limit, not an author bug. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum OnError { - /// Fail-closed: a degenerate runtime outcome denies. The APL - /// default and the safe choice for access decisions. - #[default] - Deny, - /// Fail-open: a degenerate runtime outcome allows through. - /// Intended for when CEL is a soft/advisory check layered behind a - /// hard PDP — but **APL does not enforce that layering**. Nothing - /// stops an operator from making a `cel:` step with `on_error: - /// allow` the only gate on a route, which turns every runtime error - /// into an allow. Layering is the operator's responsibility. The - /// Allow path emits `tracing::error!` (not warn) so runtime errors - /// masquerading as Allows are not invisible in production logs. - Allow, -} - -/// `PdpResolver` that evaluates CEL boolean expressions. Holds a -/// compile cache so each distinct expression string compiles a single -/// time over the resolver's lifetime. -/// Default upper bound on the compile cache. `cel:` steps are author- -/// supplied in route YAML, so the cache fills with the policy's static -/// set of distinct expressions. 1024 is generous for any realistic -/// policy file and small enough that a templating bug (or a future -/// feature that lets steps build exprs from request data) trips the -/// cap before it can balloon memory. -pub const DEFAULT_MAX_CACHE_ENTRIES: usize = 1024; - -/// A function-registration callback. The host calls -/// [`CelResolver::with_functions`] with one of these and the resolver -/// runs it against the `cel::Context` it builds for every evaluation -/// — registering whatever custom functions the host wants exposed to -/// policy authors. The callback gets full access to clarkmcc's -/// `IntoFunction` magic, so closures can be written in the natural -/// `|s: Arc, n: i64| -> bool` form, not just the raw -/// `&mut FunctionContext` shape. -/// -/// The boxed callback is `Send + Sync + 'static` because the resolver -/// is shared across worker threads via `Arc` and lives -/// for the process. -pub type CelFunctionSetup = dyn Fn(&mut Context<'static>) + Send + Sync + 'static; - -pub struct CelResolver { - dialect: PdpDialect, - on_error: OnError, - /// Upper bound on cached compiled programs. `cache_full` rejects - /// new entries past this; existing entries are never evicted (per - /// the workspace-wide "cap + reject + log, never evict" convention, - /// see `feedback_cache_eviction`). - max_cache_entries: usize, - /// Host-supplied custom-function registration callbacks. Each is - /// invoked on every freshly-built `Context` before evaluation so - /// expressions can reference the registered names. Multiple - /// callbacks compose — register e.g. one bundle of regex helpers - /// and one bundle of time helpers. - function_setups: Vec>, - /// Compiled-program cache keyed by expression source. `RwLock` so - /// the steady-state read-many path (every request hits this once - /// the route's expr has compiled the first time) is uncontended - /// — only the rare insert path takes the write lock. APL compiles - /// route YAML once, so the set of distinct exprs is small and - /// fixed; concurrent reads dominate the lifecycle. - cache: RwLock>>, -} - -impl CelResolver { - /// A resolver with default settings (`PdpDialect::Cel`, fail-closed, - /// cache capped at [`DEFAULT_MAX_CACHE_ENTRIES`]). - pub fn new() -> Self { - Self { - dialect: PdpDialect::Cel, - on_error: OnError::Deny, - max_cache_entries: DEFAULT_MAX_CACHE_ENTRIES, - function_setups: Vec::new(), - cache: RwLock::new(HashMap::new()), - } - } - - /// Set the error-handling mode (default `Deny`). - pub fn with_on_error(mut self, on_error: OnError) -> Self { - self.on_error = on_error; - self - } - - /// Override the compile-cache cap (default - /// [`DEFAULT_MAX_CACHE_ENTRIES`]). Past this bound, new exprs are - /// rejected at request time and the call is routed through - /// [`OnError`] — never evict an existing entry. Use this only when - /// you have hard evidence the default is wrong for your policy size. - pub fn with_max_cache_entries(mut self, max_cache_entries: usize) -> Self { - self.max_cache_entries = max_cache_entries; - self - } - - /// Register custom CEL functions. The supplied callback is invoked - /// against every freshly-built evaluation `Context`, so any - /// `add_function` calls it makes are available to author - /// expressions on every request. - /// - /// Composes: calling `with_functions` more than once stacks the - /// callbacks. Each runs in registration order on every context. - /// - /// # Example - /// - /// ```rust,ignore - /// use std::sync::Arc; - /// use cpex_pdp_cel::CelResolver; - /// - /// let resolver = CelResolver::new().with_functions(|ctx| { - /// // Regex helper — authors can write `args.path.matches_prefix("/api/")`. - /// ctx.add_function("matches_prefix", - /// |s: Arc, prefix: Arc| -> bool { - /// s.starts_with(prefix.as_str()) - /// }); - /// // Clock helper — authors can write `now() < session.expires_at`. - /// ctx.add_function("now", || -> i64 { - /// std::time::SystemTime::now() - /// .duration_since(std::time::UNIX_EPOCH) - /// .map(|d| d.as_secs() as i64).unwrap_or(0) - /// }); - /// }); - /// ``` - /// - /// 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 - /// - /// The custom-function set is a **host concern**, registered once - /// when the host wires up the resolver (typically via the - /// `CelPdpFactory` in the host project), not authored per-route in - /// policy YAML. The host owns the stable contract of which functions - /// exist; policy authors only call them. Adding or removing a - /// function changes that contract for every route at once, so treat - /// the set like any other host API surface — version it, and avoid - /// renaming/removing functions that live policies depend on. - pub fn with_functions(mut self, setup: F) -> Self - where - F: Fn(&mut Context<'static>) + Send + Sync + 'static, - { - self.function_setups.push(Arc::new(setup)); - self - } - - /// Override the resolver's dialect. Lets operators register a CEL - /// engine under a custom name so two CEL resolvers (e.g. different - /// `on_error` modes) can coexist on one `PdpRouter`. - pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { - self.dialect = dialect; - self - } - - /// Build a resolver from a unified-config block. Shape: - /// - /// ```yaml - /// kind: cel # matched by the factory, not read here - /// on_error: deny # optional; deny | allow, default deny - /// ``` - /// - /// The actual policy predicate isn't on this block — it's inlined - /// at each route's `cel: { expr: "..." }` step. Operators who want - /// to surface bad CEL at *deploy* time rather than at *first - /// request* should ship a CI smoke test that calls - /// `load_config_yaml` against their config and exercises one - /// request per `cel:` step; this resolver doesn't carry an - /// eager-compile knob of its own. - pub fn from_config(value: &serde_yaml::Value) -> Result { - let map = value - .as_mapping() - .ok_or_else(|| BuildError::ConfigShape("CEL PDP config must be a mapping".into()))?; - - // Reject unknown keys so a typo (`on_errr: deny`) fails loud at - // load rather than being silently dropped and defaulting. `kind` - // is consumed by the visitor/factory but is present on the block; - // `on_error` is the only knob this resolver reads. - const KNOWN_KEYS: &[&str] = &["kind", "on_error"]; - for (key, _) in map { - let Some(name) = key.as_str() else { - return Err(BuildError::ConfigShape( - "CEL PDP config keys must be strings".into(), - )); - }; - if !KNOWN_KEYS.contains(&name) { - return Err(BuildError::ConfigShape(format!( - "unknown CEL PDP config key `{name}`; expected one of {KNOWN_KEYS:?}" - ))); - } - } - - let on_error = match read_yaml_string(map, "on_error").as_deref() { - None | Some("deny") => OnError::Deny, - Some("allow") => OnError::Allow, - Some(other) => { - return Err(BuildError::ConfigShape(format!( - "`on_error` must be `deny` or `allow`, got `{other}`" - ))); - }, - }; - - Ok(Self::new().with_on_error(on_error)) - } - - /// Get a compiled program for `expr` from the cache, compiling and - /// caching it on first use. - /// - /// Read-many fast path under the `RwLock` (uncontended once the - /// route's expr has compiled); first-miss falls through to the - /// write lock to compile + insert. APL compiles all routes at - /// `load_config_yaml` time — single-threaded — so the realistic - /// race window is zero. A duplicate concurrent compile would - /// merely overwrite an equivalent entry and drop the loser's - /// `Arc`, so no extra double-checked-locking machinery is - /// warranted here. - /// - /// Cap enforcement: at `max_cache_entries` the next *new* expr is - /// rejected with `CacheFull`. The caller treats it as a degenerate - /// outcome and routes through [`OnError`]. Existing entries are - /// never evicted. - fn get_or_compile(&self, expr: &str) -> Result, GetOrCompileError> { - if let Some(program) = self - .cache - .read() - .unwrap_or_else(|p| p.into_inner()) - .get(expr) - { - return Ok(Arc::clone(program)); - } - let program = Arc::new( - Program::compile(expr).map_err(|e| GetOrCompileError::Compile(e.to_string()))?, - ); - let mut cache = self.cache.write().unwrap_or_else(|p| p.into_inner()); - if cache.len() >= self.max_cache_entries && !cache.contains_key(expr) { - tracing::warn!( - cap = self.max_cache_entries, - "CEL compile cache full; rejecting new expression. Existing entries are not \ - evicted. Increase `with_max_cache_entries` if your policy legitimately exceeds \ - the default bound." - ); - return Err(GetOrCompileError::CacheFull { - cap: self.max_cache_entries, - }); - } - cache.insert(expr.to_string(), Arc::clone(&program)); - Ok(program) - } - - /// Apply the `on_error` policy to a degenerate RUNTIME outcome - /// (eval error, non-boolean result, cache-full rejection), - /// producing a `PdpDecision` with the cause recorded in - /// diagnostics. Allow uses `tracing::error!` (not warn) so an - /// operator misusing the flag sees it loudly in production logs. - /// - /// Compile errors do NOT come through here — see - /// [`Self::compile_error_decision`]. - fn on_error_decision(&self, cause: String) -> PdpDecision { - match self.on_error { - OnError::Allow => { - tracing::error!( - cause = %cause, - "CEL runtime error; on_error=allow → allowing through. \ - This is fail-open behavior; verify it is intentional." - ); - PdpDecision { - decision: Decision::Allow, - diagnostics: vec![cause], - } - }, - OnError::Deny => PdpDecision { - decision: Decision::Deny { - reason: Some(cause.clone()), - rule_source: "cel".to_string(), - }, - diagnostics: vec![cause], - }, - } - } - - /// Compile errors always fail closed — a malformed `expr` is an - /// author bug, not a runtime condition, and silently flipping it - /// to Allow would let broken policy bypass the gate. Logs at - /// `error!` so the operator notices in CI / production. - fn compile_error_decision(&self, cause: String) -> PdpDecision { - tracing::error!( - cause = %cause, - "CEL compile error — author-supplied expression failed to parse. \ - Denying request regardless of on_error mode." - ); - PdpDecision { - decision: Decision::Deny { - reason: Some(cause.clone()), - rule_source: "cel".to_string(), - }, - diagnostics: vec![cause], - } - } -} - -impl Default for CelResolver { - fn default() -> Self { - Self::new() - } -} - -/// Internal — failure shapes from `get_or_compile`. Folds into -/// `on_error_decision` at the eval call site; not part of the public -/// surface. -enum GetOrCompileError { - Compile(String), - CacheFull { cap: usize }, -} - -impl std::fmt::Display for GetOrCompileError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Compile(e) => write!(f, "CEL compile error: {e}"), - Self::CacheFull { cap } => write!( - f, - "CEL compile-cache full (cap={cap}); refusing to compile a new expression. \ - Bump `with_max_cache_entries` if the policy legitimately needs more, otherwise \ - investigate a templating or generation bug producing unbounded distinct exprs." - ), - } - } -} - -#[async_trait] -impl PdpResolver for CelResolver { - fn dialect(&self) -> PdpDialect { - self.dialect.clone() - } - - async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result { - // 1. Pull the expression text from the step args. A `cel:` step - // with no `expr` string is an author/config bug — hard error. - let expr = call - .args - .as_mapping() - .and_then(|m| m.get(serde_yaml::Value::String("expr".into()))) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - PdpError::Dispatch("cel:() step requires a string `expr` argument".to_string()) - })?; - - // 2. Compile (cached). Compile errors always Deny (an author - // bug, never legitimately flippable). Cache-full rejections - // are runtime conditions and route through on_error. - let program = match self.get_or_compile(expr) { - Ok(p) => p, - Err(e @ GetOrCompileError::Compile(_)) => { - return Ok(self.compile_error_decision(e.to_string())); - }, - Err(e @ GetOrCompileError::CacheFull { .. }) => { - return Ok(self.on_error_decision(e.to_string())); - }, - }; - - // 3. Build the activation from the bag + author-supplied extra - // args. Then layer any host-supplied custom-function bundles - // on top so expressions can call into them. Setups run in - // registration order; later setups can shadow earlier ones, - // which is the documented contract. - let mut ctx = bag_to_context(bag, &call.args); - for setup in &self.function_setups { - setup(&mut ctx); - } - - // 4. Evaluate and map the result to a decision. - match program.execute(&ctx) { - Ok(Value::Bool(true)) => Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }), - Ok(Value::Bool(false)) => { - // Enrich the deny diagnostics with a snapshot of the - // bag values the expression actually references, so an - // auditor can see WHY without re-running with debug - // logging. Bounded — a typical predicate touches 2-5 - // namespaces. - let mut diagnostics = vec![format!("cel: {expr}")]; - diagnostics.extend(snapshot_referenced_bag_values(&program, bag)); - Ok(PdpDecision { - decision: Decision::Deny { - reason: Some("CEL expression evaluated to false".to_string()), - rule_source: "cel".to_string(), - }, - diagnostics, - }) - }, - Ok(other) => { - Ok(self - .on_error_decision(format!("CEL expression must return bool, got {other:?}"))) - }, - Err(e) => { - // Eval errors are usually undeclared-variable typos. - // Enumerate the variables the expression references AND - // which ones the bag actually has, so the operator can - // see which name they meant. - let mut cause = format!("CEL eval error: {e}"); - let refs = program.references(); - let referenced: Vec<&str> = refs.variables(); - if !referenced.is_empty() { - let mut found = referenced - .iter() - .filter(|n| bag_namespace_present(bag, n)) - .copied() - .collect::>(); - found.sort_unstable(); - let mut missing = referenced - .iter() - .filter(|n| !bag_namespace_present(bag, n)) - .copied() - .collect::>(); - missing.sort_unstable(); - cause.push_str(&format!( - " (expr references variables: {referenced:?}; \ - present in bag: {found:?}; missing: {missing:?})" - )); - } - Ok(self.on_error_decision(cause)) - }, - } - } -} - -/// Snapshot all bag entries whose dotted-key first segment matches any -/// of the top-level names the CEL expression references. Emits one -/// diagnostic string per matched key in `key=value` form. Used to -/// enrich Deny diagnostics so auditors can see what made the predicate -/// false without re-running with debug logging. -fn snapshot_referenced_bag_values(program: &Program, bag: &AttributeBag) -> Vec { - let refs = program.references(); - let referenced = refs.variables(); - if referenced.is_empty() { - return Vec::new(); - } - let referenced_set: std::collections::HashSet<&str> = referenced.iter().copied().collect(); - - let mut snapshot: Vec = bag - .iter() - .filter(|(key, _)| { - let head = key.split('.').next().unwrap_or(key); - referenced_set.contains(head) - }) - .map(|(key, value)| format!("{key}={value:?}")) - .collect(); - snapshot.sort_unstable(); - snapshot -} - -/// Does the bag have any key whose dotted-prefix first segment matches -/// `name`? Used to classify referenced variables as present-or-missing -/// in eval-error diagnostics. -fn bag_namespace_present(bag: &AttributeBag, name: &str) -> bool { - bag.iter().any(|(key, _)| { - let head: &str = key.split('.').next().unwrap_or(key); - head == name - }) -} - -/// Read a string field from a YAML mapping (mirrors the cedar-direct helper). -fn read_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { - map.get(serde_yaml::Value::String(key.to_string()))? - .as_str() - .map(|s| s.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cel_call(expr: &str) -> PdpCall { - let mut m = serde_yaml::Mapping::new(); - m.insert( - serde_yaml::Value::String("expr".into()), - serde_yaml::Value::String(expr.into()), - ); - PdpCall { - dialect: PdpDialect::Cel, - args: serde_yaml::Value::Mapping(m), - } - } - - fn bag_with(pairs: &[(&str, &str)]) -> AttributeBag { - let mut bag = AttributeBag::new(); - for (k, v) in pairs { - bag.set(*k, *v); - } - bag - } - - #[tokio::test] - async fn true_allows_false_denies() { - let r = CelResolver::new(); - let bag = bag_with(&[("subject.id", "alice")]); - - let allow = r - .evaluate(&cel_call("subject.id == 'alice'"), &bag) - .await - .unwrap(); - assert_eq!(allow.decision, Decision::Allow); - - let deny = r - .evaluate(&cel_call("subject.id == 'bob'"), &bag) - .await - .unwrap(); - assert!(matches!(deny.decision, Decision::Deny { .. })); - } - - #[tokio::test] - async fn missing_expr_is_dispatch_error() { - let r = CelResolver::new(); - let call = PdpCall { - dialect: PdpDialect::Cel, - args: serde_yaml::Value::Null, - }; - let err = r.evaluate(&call, &AttributeBag::new()).await.unwrap_err(); - assert!(matches!(err, PdpError::Dispatch(_))); - } - - /// A host can register a custom CEL function via `with_functions` - /// and expressions can call it. Registration composes: a second - /// `with_functions` call stacks on top of the first. - #[tokio::test] - async fn custom_function_registration_round_trips() { - let r = CelResolver::new() - .with_functions(|ctx| { - ctx.add_function("host_twice", |n: i64| -> i64 { n * 2 }); - }) - .with_functions(|ctx| { - ctx.add_function("shout", |s: Arc| -> String { s.to_uppercase() }); - }); - let bag = bag_with(&[("subject.id", "alice")]); - - // First registered function works. - let out = r - .evaluate(&cel_call("host_twice(21) == 42"), &bag) - .await - .unwrap(); - assert_eq!( - out.decision, - Decision::Allow, - "first registered function must be callable", - ); - - // Second composed function works (and reads a bag value). - let out = r - .evaluate(&cel_call("shout(subject.id) == 'ALICE'"), &bag) - .await - .unwrap(); - assert_eq!( - out.decision, - Decision::Allow, - "subsequent with_functions calls must compose, not replace", - ); - } - - /// The `regex` cel-feature is explicitly enabled in our Cargo.toml. - /// Pin that `matches(s, pattern)` actually works through the - /// resolver so a future feature-set churn breaks loudly here. - #[tokio::test] - async fn matches_regex_function_is_available() { - let r = CelResolver::new(); - let bag = bag_with(&[("args.path", "/api/v1/tools/call")]); - let out = r - .evaluate(&cel_call("args.path.matches('^/api/v[0-9]+/')"), &bag) - .await - .unwrap(); - assert_eq!( - out.decision, - Decision::Allow, - "the regex CEL feature must be enabled so authors can match paths", - ); - } - - #[tokio::test] - async fn undeclared_variable_fails_closed_by_default() { - let r = CelResolver::new(); - // `nonexistent` is not in the bag → eval error → fail-closed Deny. - let out = r - .evaluate(&cel_call("nonexistent.field == 1"), &AttributeBag::new()) - .await - .unwrap(); - assert!(matches!(out.decision, Decision::Deny { .. })); - } - - /// On Deny, diagnostics include a snapshot of the bag values for - /// every top-level namespace the expression references. Auditors - /// reading the diagnostics see WHY the predicate evaluated false - /// without re-running with debug logging. - #[tokio::test] - async fn deny_diagnostics_snapshot_referenced_bag_values() { - let r = CelResolver::new(); - let bag = bag_with(&[ - ("subject.id", "eve"), - ("subject.type", "user"), - ("unrelated.key", "ignore-me"), - ]); - let out = r - .evaluate(&cel_call("subject.id == 'alice'"), &bag) - .await - .unwrap(); - assert!(matches!(out.decision, Decision::Deny { .. })); - let snapshot = out - .diagnostics - .iter() - .find(|d| d.contains("subject.id=")) - .unwrap_or_else(|| panic!("expected subject.id snapshot; got {:?}", out.diagnostics)); - assert!( - snapshot.contains("\"eve\""), - "snapshot must carry the actual bag value; got {snapshot:?}", - ); - // Unrelated namespaces stay out — keeps the diagnostic bounded. - assert!( - !out.diagnostics.iter().any(|d| d.contains("unrelated")), - "snapshot must be scoped to referenced namespaces; got {:?}", - out.diagnostics, - ); - } - - /// On an eval error (undeclared variable), the cause string lists - /// the referenced variables AND classifies them present-vs-missing - /// in the bag, so the operator can see which typo they made. - #[tokio::test] - async fn eval_error_diagnostics_classify_referenced_variables() { - let r = CelResolver::new(); - let bag = bag_with(&[("subject.id", "alice")]); - // `subjcet` is a typo for `subject` — eval error, fail-closed. - let out = r - .evaluate(&cel_call("subjcet.id == 'alice'"), &bag) - .await - .unwrap(); - let cause = match out.decision { - Decision::Deny { reason, .. } => reason.unwrap_or_default(), - other => panic!("expected Deny; got {other:?}"), - }; - assert!( - cause.contains("missing: [\"subjcet\"]"), - "cause must classify the typo as missing; got {cause:?}", - ); - } - - /// A malformed `expr` always Denies, even with `on_error: allow`. - /// Compile errors are author bugs — silently flipping them to Allow - /// would let broken policy bypass the gate. Pins the asymmetry - /// between compile errors and runtime errors. - #[tokio::test] - async fn compile_error_always_denies_even_with_on_error_allow() { - let r = CelResolver::new().with_on_error(OnError::Allow); - // `1 +` is a syntax error → compile failure → unconditional Deny. - let out = r - .evaluate(&cel_call("1 +"), &AttributeBag::new()) - .await - .unwrap(); - match out.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!(rule_source, "cel"); - let r = reason.unwrap_or_default(); - assert!( - r.contains("compile error"), - "deny reason must name the compile failure; got {r:?}", - ); - }, - other => panic!("compile error must deny regardless of on_error; got {other:?}"), - } - } - - #[tokio::test] - async fn on_error_allow_flips_eval_error_to_allow() { - let r = CelResolver::new().with_on_error(OnError::Allow); - let out = r - .evaluate(&cel_call("nonexistent.field == 1"), &AttributeBag::new()) - .await - .unwrap(); - assert_eq!(out.decision, Decision::Allow); - } - - #[tokio::test] - async fn non_boolean_result_fails_closed() { - let r = CelResolver::new(); - let bag = bag_with(&[("subject.id", "alice")]); - // Returns a string, not a bool → degenerate → fail-closed Deny. - let out = r.evaluate(&cel_call("subject.id"), &bag).await.unwrap(); - assert!(matches!(out.decision, Decision::Deny { .. })); - } - - #[tokio::test] - async fn compile_cache_reuses_program() { - let r = CelResolver::new(); - let bag = bag_with(&[("subject.id", "alice")]); - let expr = "subject.id == 'alice'"; - let _ = r.evaluate(&cel_call(expr), &bag).await.unwrap(); - let _ = r.evaluate(&cel_call(expr), &bag).await.unwrap(); - // One distinct expr → exactly one cached program (compiled once). - let cache = r.cache.read().unwrap(); - assert_eq!(cache.len(), 1); - assert!(cache.contains_key(expr)); - } - - /// At the cache cap, the *next new* expr is rejected — but already- - /// cached exprs still evaluate normally. The rejected call is routed - /// through `on_error` (default Deny), so policy still gets a - /// decision even when the operator's cap is too tight. - #[tokio::test] - async fn cache_cap_rejects_new_exprs_but_keeps_old_ones() { - let r = CelResolver::new().with_max_cache_entries(1); - let bag = bag_with(&[("subject.id", "alice")]); - - // First expr fills the cache. - let first = r - .evaluate(&cel_call("subject.id == 'alice'"), &bag) - .await - .unwrap(); - assert_eq!(first.decision, Decision::Allow); - assert_eq!(r.cache.read().unwrap().len(), 1); - - // Second distinct expr → rejected by the cap → on_error Deny. - let second = r - .evaluate(&cel_call("subject.id != ''"), &bag) - .await - .unwrap(); - assert!( - matches!(second.decision, Decision::Deny { .. }), - "cap rejection must route through on_error Deny by default", - ); - assert!( - second.diagnostics.iter().any(|d| d.contains("cache full")), - "rejection diagnostic must name the cause; got {:?}", - second.diagnostics, - ); - assert_eq!( - r.cache.read().unwrap().len(), - 1, - "rejected expr must not be inserted", - ); - - // Cached expr still works. - let third = r - .evaluate(&cel_call("subject.id == 'alice'"), &bag) - .await - .unwrap(); - assert_eq!(third.decision, Decision::Allow); - } - - /// `on_error: allow` flips a cache-full rejection to Allow — same - /// path as compile / eval errors. Pins that the fail-open knob is - /// uniform across all degenerate outcomes. - #[tokio::test] - async fn cache_cap_respects_on_error_allow() { - let r = CelResolver::new() - .with_max_cache_entries(1) - .with_on_error(OnError::Allow); - let bag = bag_with(&[("subject.id", "alice")]); - - // Fill the cache. - let _ = r - .evaluate(&cel_call("subject.id == 'alice'"), &bag) - .await - .unwrap(); - - // Second distinct expr is cap-rejected → on_error Allow. - let out = r - .evaluate(&cel_call("subject.id != ''"), &bag) - .await - .unwrap(); - assert_eq!(out.decision, Decision::Allow); - } - - #[test] - fn from_config_parses_on_error() { - let yaml: serde_yaml::Value = serde_yaml::from_str("kind: cel\non_error: allow\n").unwrap(); - let r = CelResolver::from_config(&yaml).unwrap(); - assert_eq!(r.on_error, OnError::Allow); - } - - #[test] - fn from_config_rejects_bad_on_error() { - let yaml: serde_yaml::Value = serde_yaml::from_str("kind: cel\non_error: maybe\n").unwrap(); - assert!(matches!( - CelResolver::from_config(&yaml), - Err(BuildError::ConfigShape(_)) - )); - } - - /// An unknown config key (here `on_errr`, a typo for `on_error`) is - /// rejected at config-parse time rather than silently dropped — a - /// dropped key would mask the typo and use the default `Deny`, - /// leaving the operator believing they'd set `allow`. The error - /// names the offending key. - #[test] - fn from_config_rejects_unknown_key() { - let yaml: serde_yaml::Value = serde_yaml::from_str("kind: cel\non_errr: allow\n").unwrap(); - match CelResolver::from_config(&yaml) { - Err(BuildError::ConfigShape(msg)) => assert!( - msg.contains("on_errr"), - "error must name the unknown key; got {msg:?}", - ), - Ok(_) => panic!("unknown key `on_errr` must be rejected"), - } - } - - /// Many threads evaluating the same expression on one shared - /// resolver must all get the right decision, and the compile cache - /// must hold exactly one entry (the `RwLock` read path is - /// uncontended in steady state; this pins that concurrent reads - /// don't double-insert or deadlock). - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_evaluation_shares_one_cached_program() { - let resolver = Arc::new(CelResolver::new()); - let expr = "subject.id == 'alice'"; - - let tasks: Vec<_> = (0..64) - .map(|i| { - let r = Arc::clone(&resolver); - tokio::spawn(async move { - // Half match, half don't — exercises both Allow and - // Deny through the shared cache concurrently. - let id = if i % 2 == 0 { "alice" } else { "bob" }; - let bag = bag_with(&[("subject.id", id)]); - let out = r.evaluate(&cel_call(expr), &bag).await.unwrap(); - (id, out.decision) - }) - }) - .collect(); - - for task in tasks { - let (id, decision) = task.await.unwrap(); - if id == "alice" { - assert_eq!(decision, Decision::Allow); - } else { - assert!(matches!(decision, Decision::Deny { .. })); - } - } - - // One distinct expr → exactly one compiled program despite the - // concurrent first-miss race. - let cache = resolver.cache.read().unwrap(); - assert_eq!( - cache.len(), - 1, - "concurrent compiles must converge to one entry" - ); - assert!(cache.contains_key(expr)); - } -} diff --git a/builtins/pdps/cel/tests/visitor_cel_config.rs b/builtins/pdps/cel/tests/visitor_cel_config.rs deleted file mode 100644 index 696a5605..00000000 --- a/builtins/pdps/cel/tests/visitor_cel_config.rs +++ /dev/null @@ -1,320 +0,0 @@ -// Location: ./builtins/pdps/cel/tests/visitor_cel_config.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end integration: a unified-config YAML that -// -// 1. declares a `cel` PDP under `global.apl.pdp[]`, -// 2. attaches a `cel:(expr: "...")` policy step to a route, -// -// must flow a real decision from the cpex-core dispatcher through -// `AplConfigVisitor` → `PdpFactory` → `CelResolver` → the `cel` -// interpreter → back into the route handler's allow/deny split. -// -// This proves the *wiring* end-to-end. The crate's unit tests cover the -// bag→activation mapping and the resolver in isolation; what's special -// here is that the resolver was never instantiated in Rust by the test — -// the visitor built it from YAML at `load_config_yaml` time because the -// host registered `CelPdpFactory` via `AplOptions.pdp_factories`. If this -// passes, an operator who drops a `cel` block into their config gets the -// same behavior without writing any glue. - -use std::collections::HashSet; -use std::sync::Arc; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension, SubjectType}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; -use cpex_pdp_cel::CelPdpFactory; - -// The config the visitor walks. A `cel:` step whose expression reads the -// common attribute vocabulary (`subject.id`, `role.*`) the cmf BagBuilder -// lifts from the SecurityExtension. `has(role.reader)` guards the optional -// role namespace so a principal with no roles evaluates to a clean `false` -// (Deny) rather than an undeclared-variable error. -const YAML: &str = r#" -global: - apl: - pdp: - - kind: cel -routes: - - tool: get_document - apl: - pre_invocation: - - cel: - expr: | - subject.id == "alice" && has(role.reader) && role.reader -"#; - -fn meta_for_tool(name: &str) -> MetaExtension { - MetaExtension { - entity_type: Some("tool".to_string()), - entity_name: Some(name.to_string()), - ..Default::default() - } -} - -fn security_with_roles(id: &str, roles: &[&str]) -> SecurityExtension { - SecurityExtension { - subject: Some(SubjectExtension { - id: Some(id.to_string()), - subject_type: Some(SubjectType::User), - roles: roles.iter().map(|r| r.to_string()).collect::>(), - ..Default::default() - }), - ..Default::default() - } -} - -async fn build_manager() -> Arc { - build_manager_with_yaml(YAML) - .await - .expect("load_config_yaml") -} - -/// Build a manager from arbitrary YAML; returns the load error so -/// negative tests can inspect it. Mirrors `build_manager` but lets -/// tests swap the config text under test. -async fn build_manager_with_yaml( - yaml: &str, -) -> Result, Box> { - let mgr = Arc::new(PluginManager::default()); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - // The factory is the load-bearing wiring under test: the visitor - // sees `kind: cel` in YAML and finds this factory by key. - pdp_factories: vec![Arc::new(CelPdpFactory::new())], - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(yaml) - .map_err(|e| -> Box { format!("{e}").into() })?; - mgr.initialize() - .await - .map_err(|e| -> Box { format!("{e}").into() })?; - Ok(mgr) -} - -fn payload() -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, "fetch doc-42"), - } -} - -/// `alice` with `role.reader=true` satisfies the CEL predicate → Allow. -/// End-to-end: visitor built the resolver from YAML, route handler -/// dispatched the `cel:` step into it, CEL returned `true`, pipeline -/// continues. -#[tokio::test] -async fn config_declared_cel_pdp_allows_matching_subject() { - let mgr = build_manager().await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - result.continue_processing, - "alice+reader should satisfy the CEL predicate; got violation = {:?}", - result.violation - ); -} - -/// `eve` is not `alice` → the CEL predicate is `false` → Deny halts the -/// pipeline. (Short-circuit `&&` means the missing `role` namespace is -/// never touched.) -#[tokio::test] -async fn config_declared_cel_pdp_denies_non_matching_subject() { - let mgr = build_manager().await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("eve", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "eve should fail the subject.id check and be denied", - ); - assert!( - result.violation.is_some(), - "deny path must surface a violation", - ); -} - -/// A malformed CEL PDP config (`on_error: maybe`) must be rejected at -/// `load_config_yaml` rather than discovered on first request. The -/// visitor → `CelPdpFactory::build` → `CelResolver::from_config` chain -/// surfaces `BuildError::ConfigShape` as a `cpex_core::PluginError`, -/// which bubbles out of load. -#[tokio::test] -async fn malformed_on_error_is_rejected_at_load() { - const BAD_YAML: &str = r#" -global: - apl: - pdp: - - kind: cel - on_error: maybe -routes: - - tool: get_document - apl: - pre_invocation: - - cel: - expr: | - subject.id == "alice" -"#; - let err = match build_manager_with_yaml(BAD_YAML).await { - Ok(_) => panic!("malformed on_error must fail load_config_yaml"), - Err(e) => e, - }; - let msg = format!("{err}"); - assert!( - msg.contains("on_error") && msg.contains("maybe"), - "load error should name the bad field and value; got: {msg}", - ); -} - -/// `on_error: allow` at the config level flips an eval error (here, an -/// undeclared-variable reference) to Allow end-to-end. Pins the -/// fail-open knob travels from YAML → factory → resolver → router → -/// route-handler decision the same way as the unit-level resolver test. -#[tokio::test] -async fn on_error_allow_yaml_flips_eval_error_to_allow_end_to_end() { - const ALLOW_YAML: &str = r#" -global: - apl: - pdp: - - kind: cel - on_error: allow -routes: - - tool: get_document - apl: - pre_invocation: - - cel: - expr: | - nonexistent.field == "value" -"#; - let mgr = build_manager_with_yaml(ALLOW_YAML) - .await - .expect("on_error: allow config must load cleanly"); - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - result.continue_processing, - "eval error under on_error=allow must surface as Allow; got violation = {:?}", - result.violation, - ); -} - -/// A `cel:` step with no `expr` (the author wrote reactions but forgot -/// the predicate) is an author bug that the parser accepts opaquely — -/// the resolver only learns of it at request time. It must surface as a -/// clean Deny ("PDP error") that halts the pipeline, never a panic. -/// Complements the unit-level `missing_expr_is_dispatch_error` by -/// proving the error travels through the real dispatcher. -#[tokio::test] -async fn missing_expr_at_request_time_denies_without_panicking() { - const NO_EXPR_YAML: &str = r#" -global: - apl: - pdp: - - kind: cel -routes: - - tool: get_document - apl: - pre_invocation: - - cel: - on_deny: - - deny -"#; - let mgr = build_manager_with_yaml(NO_EXPR_YAML) - .await - .expect("a cel step without expr is accepted at parse/load time"); - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "a missing-expr cel step must halt the pipeline, not allow through", - ); - assert!( - result.violation.is_some(), - "missing-expr dispatch error must surface as a violation", - ); -} - -/// A `cel:` predicate that reads the `meta` namespace -/// (`meta.entity_name`) proves the cmf BagBuilder lifts `MetaExtension` -/// into the bag and the activation exposes it to CEL — the other -/// integration cases only exercise `subject.*` / `role.*` from the -/// SecurityExtension. Gates the tool by name end-to-end. -#[tokio::test] -async fn cel_reads_meta_entity_name_from_bag() { - const META_YAML: &str = r#" -global: - apl: - pdp: - - kind: cel -routes: - - tool: get_document - apl: - pre_invocation: - - cel: - expr: | - meta.entity_name == "get_document" -"#; - let mgr = build_manager_with_yaml(META_YAML) - .await - .expect("load_config_yaml"); - - // Matching tool name → predicate true → Allow. - let allow_ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - let (allow, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), allow_ext, None) - .await; - assert!( - allow.continue_processing, - "meta.entity_name == \"get_document\" must reach CEL and allow; got violation = {:?}", - allow.violation, - ); -} diff --git a/builtins/pdps/opa/Cargo.toml b/builtins/pdps/opa/Cargo.toml deleted file mode 100644 index f5f13167..00000000 --- a/builtins/pdps/opa/Cargo.toml +++ /dev/null @@ -1,93 +0,0 @@ -# Location: ./builtins/pdps/opa/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Fred Araujo -# -# cpex-pdp-opa — a `PdpResolver` that evaluates OPA/Rego policy in-process via -# the pure-Rust `regorus` interpreter. Global and/or inline Rego modules plus -# external `data`; the request `AttributeBag` maps to the Rego `input` -# document; the configured query resolves to allow/deny. -# -# See the crate-level module docs in `src/lib.rs` for the full picture: where -# it sits in the stack, the bag→input mapping, the decision contract, and the -# base-engine / clone-per-request evaluation model. - -[package] -name = "cpex-pdp-opa" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX PDP — embedded OPA/Rego policy evaluation via regorus." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -apl-core = { workspace = true } -# regorus — Microsoft's pure-Rust Rego interpreter (Apache-2.0). -# -# `default-features = false` is deliberate. regorus's `default` -# (`arc + full-opa + rvm`) bundles the `http`, `net`, `opa-runtime`, and -# `jsonschema` builtins. Inline Rego is authored by a less-trusted actor than -# the operator, so this crate opts out of that surface and enables only the -# pure, local builtins an authorization predicate needs — the same discipline -# `cpex-pdp-cel` applies when it pins CEL features explicitly. Excluded on -# purpose: -# - http → `http.send` network egress -# - net → ipnet/CIDR builtins -# - opa-runtime → `opa.runtime()` environment/config introspection -# - jsonschema → large dependency, unused for allow/deny predicates -# `arc` is load-bearing: it switches regorus's internal reference counting to -# atomic `Arc` so `Engine` is `Send`/`Sync` and the resolver can be shared -# across worker threads behind `Arc`. -regorus = { version = "0.11", default-features = false, features = [ - "arc", - "std", - "rvm", - "regex", - "time", - "base64", - "base64url", - "hex", - "glob", - "graph", - "semver", - "urlquery", - "uuid", - "yaml", - # `cache` bounds and reuses compiled regex and glob patterns in a - # process-global LRU keyed by pattern text (regorus defaults: 256 regex, 128 - # glob). It caches compiled patterns only, never evaluation results, so it - # holds no input-derived state and is safe to share across requests. Without - # it, every regex and glob in a policy recompiles on every evaluation. - "cache", -] } -serde_json = { workspace = true } -async-trait = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -# Rego evaluation is synchronous and can be CPU-heavy (comprehensions over -# large `data`, expensive builtins). The resolver runs each eval on -# `tokio::task::spawn_blocking` so one request cannot monopolize an async -# worker thread. This couples the crate to a tokio runtime at eval time — a -# deliberate divergence from `cpex-pdp-cel` (which evaluates inline), justified -# because Rego eval is materially heavier than a CEL predicate. The CPEX host -# already runs on tokio. -tokio = { workspace = true, features = ["rt"] } - -[dev-dependencies] -# End-to-end integration tests wire the opa factory through the apl-cpex -# visitor and exercise it against a real `PluginManager`. These dev-dep edges -# only exist for tests — the crate depends only on apl-core (plus its engine -# and runtime deps) at compile time, so it can be used standalone. -apl-cmf = { workspace = true } -apl-cpex = { workspace = true } -cpex-core = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } - -[lints] -workspace = true diff --git a/builtins/pdps/opa/src/decision.rs b/builtins/pdps/opa/src/decision.rs deleted file mode 100644 index 7f7283bb..00000000 --- a/builtins/pdps/opa/src/decision.rs +++ /dev/null @@ -1,476 +0,0 @@ -// Location: ./builtins/pdps/opa/src/decision.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Query-result → decision mapping. -// -// A Rego query resolves to a `regorus::Value`. This module turns that value -// into an APL `PdpDecision` per the decision contract: -// -// - Bool(true) → Allow -// - Bool(false) → Deny (query evaluated to false) -// - Object → read the decision field (default -// `allow`) as a bool; true → Allow, -// false → Deny enriched with the -// object's reason/message, violations, -// and rule id -// - Set / Array (deny-set idiom) → empty → Allow; non-empty → Deny with -// the elements as violations -// - Undefined → clean Deny (idiomatic "not granted"), -// independent of on_error -// - anything else, or an object whose → Degenerate: the caller routes this -// decision field is missing/non-bool through `on_error` -// -// A Deny carries a human-readable `reason`, a `rule_source` (a policy-supplied -// id when present, else `"opa"`), and diagnostics detailing the cause so an -// auditor can see why without re-running the policy. Diagnostics are bounded in -// both element count and line length, since their content is policy-authored -// and can be derived from arbitrarily large `data`. Truncation is always marked, -// so a bounded diagnostic never reads as a complete one. - -use regorus::Value; - -use apl_core::evaluator::Decision; -use apl_core::step::PdpDecision; - -/// The fallback attribution when a policy does not name a rule id. -const DEFAULT_RULE_SOURCE: &str = "opa"; - -/// Bounds on the diagnostics one deny may carry. A decision object and a deny -/// set are policy-authored and can derive their elements from a large `data` -/// table, so both the number of lines and the length of each line are capped -/// before an audit sink sees them. Worst case for a single deny is roughly -/// 35 KiB: two capped element lists plus the whole-object line. -const MAX_DIAGNOSTIC_ELEMENTS: usize = 16; -const MAX_DIAGNOSTIC_LEN: usize = 1024; - -/// Outcome of mapping a query result. A `Decision` is terminal (allow/deny); -/// `Degenerate` means the value carries no decision and the caller applies -/// `on_error`. -pub(crate) enum Mapped { - Decision(PdpDecision), - Degenerate(String), -} - -/// Map a successful query result into a decision (or a degenerate marker). -pub(crate) fn map_query_result(value: &Value, decision_field: &str) -> Mapped { - match value { - Value::Bool(true) => Mapped::Decision(allow()), - Value::Bool(false) => Mapped::Decision(deny( - "OPA query evaluated to false".to_string(), - DEFAULT_RULE_SOURCE.to_string(), - Vec::new(), - )), - Value::Object(_) => map_object(value, decision_field), - Value::Set(items) => map_collection(items.iter()), - Value::Array(items) => map_collection(items.iter()), - // Undefined is Rego's idiomatic "no rule granted access" — a clean - // deny, never routed through on_error (so on_error: allow cannot flip - // an ordinary non-match to allow). - Value::Undefined => Mapped::Decision(deny( - "OPA query undefined — request not granted".to_string(), - DEFAULT_RULE_SOURCE.to_string(), - Vec::new(), - )), - other => Mapped::Degenerate(format!( - "OPA query returned a value that carries no decision: {}", - render(other) - )), - } -} - -/// Object result: the decision boolean comes from `decision_field`. -fn map_object(value: &Value, decision_field: &str) -> Mapped { - let obj = match value.as_object() { - Ok(o) => o, - // Unreachable given the caller's `Value::Object` match; defensive. - Err(_) => return Mapped::Degenerate("OPA query object was not an object".to_string()), - }; - - let decision = obj - .get(&Value::from(decision_field)) - .and_then(|v| v.as_bool().ok().copied()); - - match decision { - Some(true) => Mapped::Decision(allow()), - Some(false) => { - let reason = get_str(obj, "reason") - .or_else(|| get_str(obj, "message")) - .unwrap_or_else(|| "OPA policy denied the request".to_string()); - let rule_source = get_str(obj, "rule_source") - .or_else(|| get_str(obj, "id")) - .unwrap_or_else(|| DEFAULT_RULE_SOURCE.to_string()); - - let mut diagnostics = Vec::new(); - // Recognized violation lists become individual diagnostics. - for key in ["violations", "errors"] { - if let Some(list) = obj.get(&Value::from(key)).and_then(|v| v.as_array().ok()) { - diagnostics.extend(bounded_elements(list.iter())); - } - } - // Serialize the whole object so nothing the author returned is lost - // to the audit trail, even fields we did not specifically read. - diagnostics.push(bounded(format!("opa: {}", render(value)))); - - Mapped::Decision(deny(reason, rule_source, diagnostics)) - }, - None => Mapped::Degenerate(format!( - "OPA decision object has no boolean `{decision_field}` field: {}", - render(value) - )), - } -} - -/// Set/array (deny-set / violation-set idiom): empty → allow, non-empty → deny -/// with the elements as violations. -fn map_collection<'a>(items: impl ExactSizeIterator) -> Mapped { - // Count before consuming: the reason reports the true violation count even - // when the diagnostics below are capped, so an auditor is never told "3 - // violations" when the policy produced 300. - let total = items.len(); - if total == 0 { - return Mapped::Decision(allow()); - } - let reason = format!("OPA policy produced {total} violation(s)"); - Mapped::Decision(deny( - reason, - DEFAULT_RULE_SOURCE.to_string(), - bounded_elements(items), - )) -} - -fn allow() -> PdpDecision { - PdpDecision { - decision: Decision::Allow, - diagnostics: Vec::new(), - } -} - -fn deny(reason: String, rule_source: String, diagnostics: Vec) -> PdpDecision { - PdpDecision { - decision: Decision::Deny { - reason: Some(reason), - rule_source, - }, - diagnostics, - } -} - -/// Read an object field as a string, if present and string-typed. -fn get_str(obj: ®orus::value::Object, key: &str) -> Option { - obj.get(&Value::from(key)) - .and_then(|v| v.as_string().ok()) - .map(|s| s.to_string()) -} - -/// Cap one diagnostic line, cutting on a UTF-8 boundary and noting how much was -/// dropped so an auditor can tell a bounded line from a complete one. -fn bounded(s: String) -> String { - if s.len() <= MAX_DIAGNOSTIC_LEN { - return s; - } - let cut = s.floor_char_boundary(MAX_DIAGNOSTIC_LEN); - format!("{}... [{} more bytes omitted]", &s[..cut], s.len() - cut) -} - -/// Render at most [`MAX_DIAGNOSTIC_ELEMENTS`] elements, each capped, followed by -/// a count line when the list was longer. -fn bounded_elements<'a>(items: impl ExactSizeIterator) -> Vec { - let total = items.len(); - let mut out: Vec = items - .take(MAX_DIAGNOSTIC_ELEMENTS) - .map(|item| bounded(render(item))) - .collect(); - if total > out.len() { - out.push(format!("[{} more element(s) omitted]", total - out.len())); - } - out -} - -/// Render a value for a diagnostic line: a bare string as-is, everything else -/// as compact JSON (falling back to a placeholder if it cannot be rendered). -fn render(value: &Value) -> String { - if let Ok(s) = value.as_string() { - return s.to_string(); - } - value - .to_json_str() - .unwrap_or_else(|_| "".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn val(json: &str) -> Value { - Value::from_json_str(json).unwrap() - } - - fn decision_of(m: Mapped) -> Decision { - match m { - Mapped::Decision(d) => d.decision, - Mapped::Degenerate(c) => panic!("expected a decision, got degenerate: {c}"), - } - } - - #[test] - fn bool_true_allows_false_denies() { - assert_eq!( - decision_of(map_query_result(&Value::Bool(true), "allow")), - Decision::Allow - ); - assert!(matches!( - decision_of(map_query_result(&Value::Bool(false), "allow")), - Decision::Deny { .. } - )); - } - - #[test] - fn undefined_is_clean_deny() { - assert!(matches!( - decision_of(map_query_result(&Value::Undefined, "allow")), - Decision::Deny { rule_source, .. } if rule_source == "opa" - )); - } - - #[test] - fn object_allow_true_allows() { - assert_eq!( - decision_of(map_query_result(&val(r#"{"allow": true}"#), "allow")), - Decision::Allow - ); - } - - #[test] - fn object_deny_carries_reason_and_violations() { - let m = map_query_result( - &val( - r#"{"allow": false, "reason": "subject not in allowlist", "violations": ["no reader role"]}"#, - ), - "allow", - ); - match m { - Mapped::Decision(d) => { - match d.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!(reason.as_deref(), Some("subject not in allowlist")); - assert_eq!(rule_source, "opa"); - }, - other => panic!("expected Deny, got {other:?}"), - } - assert!( - d.diagnostics.iter().any(|x| x == "no reader role"), - "violations must appear in diagnostics; got {:?}", - d.diagnostics - ); - }, - Mapped::Degenerate(c) => panic!("degenerate: {c}"), - } - } - - #[test] - fn object_message_field_used_when_no_reason() { - let m = map_query_result(&val(r#"{"allow": false, "message": "blocked"}"#), "allow"); - match decision_of(m) { - Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("blocked")), - other => panic!("expected Deny, got {other:?}"), - } - } - - #[test] - fn object_policy_id_becomes_rule_source() { - let m = map_query_result( - &val(r#"{"allow": false, "rule_source": "owner-override"}"#), - "allow", - ); - match decision_of(m) { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "owner-override"), - other => panic!("expected Deny, got {other:?}"), - } - } - - #[test] - fn custom_decision_field_is_honored() { - assert_eq!( - decision_of(map_query_result(&val(r#"{"permit": true}"#), "permit")), - Decision::Allow - ); - } - - #[test] - fn object_without_decision_field_is_degenerate() { - assert!(matches!( - map_query_result(&val(r#"{"note": "hi"}"#), "allow"), - Mapped::Degenerate(_) - )); - } - - #[test] - fn object_non_bool_decision_field_is_degenerate() { - assert!(matches!( - map_query_result(&val(r#"{"allow": "yes"}"#), "allow"), - Mapped::Degenerate(_) - )); - } - - #[test] - fn empty_array_allows_nonempty_denies() { - assert_eq!( - decision_of(map_query_result(&val("[]"), "allow")), - Decision::Allow - ); - match decision_of(map_query_result(&val(r#"["blocked: reason"]"#), "allow")) { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "opa"), - other => panic!("expected Deny, got {other:?}"), - } - } - - #[test] - fn nonempty_set_denies_with_violations() { - use std::collections::BTreeSet; - let mut set = BTreeSet::new(); - set.insert(Value::from("no reader role")); - let m = map_query_result(&Value::from(set), "allow"); - match m { - Mapped::Decision(d) => { - assert!(matches!(d.decision, Decision::Deny { .. })); - assert!(d.diagnostics.iter().any(|x| x == "no reader role")); - }, - Mapped::Degenerate(c) => panic!("degenerate: {c}"), - } - } - - #[test] - fn string_result_is_degenerate() { - assert!(matches!( - map_query_result(&Value::from("hello"), "allow"), - Mapped::Degenerate(_) - )); - } - - #[test] - fn object_errors_key_lands_in_diagnostics() { - let m = map_query_result( - &val(r#"{"allow": false, "errors": ["policy failed", "missing role"]}"#), - "allow", - ); - match m { - Mapped::Decision(d) => { - assert!(d.diagnostics.iter().any(|x| x == "policy failed")); - assert!(d.diagnostics.iter().any(|x| x == "missing role")); - }, - Mapped::Degenerate(c) => panic!("degenerate: {c}"), - } - } - - #[test] - fn object_id_field_is_rule_source_fallback() { - let m = map_query_result(&val(r#"{"allow": false, "id": "rule-42"}"#), "allow"); - match decision_of(m) { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "rule-42"), - other => panic!("expected Deny, got {other:?}"), - } - } - - fn diagnostics_of(m: Mapped) -> Vec { - match m { - Mapped::Decision(d) => d.diagnostics, - Mapped::Degenerate(c) => panic!("expected a decision, got degenerate: {c}"), - } - } - - #[test] - fn oversized_violation_list_is_capped() { - let violations: Vec = (0..100).map(|i| format!("\"v{i}\"")).collect(); - let m = map_query_result( - &val(&format!( - r#"{{"allow": false, "violations": [{}]}}"#, - violations.join(",") - )), - "allow", - ); - let diagnostics = diagnostics_of(m); - // Capped elements, one omitted-count line, one whole-object line. - assert_eq!(diagnostics.len(), MAX_DIAGNOSTIC_ELEMENTS + 2); - assert!( - diagnostics - .iter() - .any(|d| d == "[84 more element(s) omitted]"), - "truncation must be marked; got {diagnostics:?}" - ); - } - - #[test] - fn oversized_violation_element_is_truncated() { - let long = "x".repeat(10_000); - let m = map_query_result( - &val(&format!(r#"{{"allow": false, "violations": ["{long}"]}}"#)), - "allow", - ); - let diagnostics = diagnostics_of(m); - for line in &diagnostics { - assert!( - line.len() < MAX_DIAGNOSTIC_LEN + 64, - "line of {} bytes exceeds the bound", - line.len() - ); - } - assert!( - diagnostics.iter().all(|d| d.contains("more bytes omitted")), - "both the element and the whole-object line must be marked truncated; got \ - {diagnostics:?}" - ); - } - - /// The deny reason must report the real violation count even when the - /// diagnostics are capped, so an auditor is never told "16 violations" when - /// the policy produced 100. - #[test] - fn capped_deny_set_reason_reports_true_count() { - let elements: Vec = (0..100).map(|i| format!("\"v{i}\"")).collect(); - let m = map_query_result(&val(&format!("[{}]", elements.join(","))), "allow"); - match m { - Mapped::Decision(d) => { - match d.decision { - Decision::Deny { reason, .. } => { - assert_eq!( - reason.as_deref(), - Some("OPA policy produced 100 violation(s)") - ); - }, - other => panic!("expected Deny, got {other:?}"), - } - assert_eq!(d.diagnostics.len(), MAX_DIAGNOSTIC_ELEMENTS + 1); - }, - Mapped::Degenerate(c) => panic!("degenerate: {c}"), - } - } - - #[test] - fn truncation_cuts_on_utf8_boundary() { - // Three-byte characters do not divide MAX_DIAGNOSTIC_LEN evenly, so the - // cut lands mid-character unless the boundary is respected. - let long = "\u{4e16}".repeat(1_000); - let out = bounded(long); - assert!(out.contains("more bytes omitted")); - assert!(out.len() <= MAX_DIAGNOSTIC_LEN + 64); - } - - /// The decision field is authoritative: `allow: true` allows even if the - /// object also carries a populated `violations` list. Pins the documented - /// precedence so a future change to the object path is a conscious choice. - #[test] - fn allow_true_wins_over_populated_violations() { - assert_eq!( - decision_of(map_query_result( - &val(r#"{"allow": true, "violations": ["ignored"]}"#), - "allow" - )), - Decision::Allow - ); - } -} diff --git a/builtins/pdps/opa/src/error.rs b/builtins/pdps/opa/src/error.rs deleted file mode 100644 index 74167a09..00000000 --- a/builtins/pdps/opa/src/error.rs +++ /dev/null @@ -1,48 +0,0 @@ -// Location: ./builtins/pdps/opa/src/error.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Build-time errors for the OPA PDP. These surface at config-load time (when -// the apl-cpex visitor calls the factory), so an operator sees bad policy or -// malformed config at deploy rather than on the first request. Mirrors the -// shape of `cpex-pdp-cedar-direct`'s `BuildError`. - -use thiserror::Error; - -/// Errors produced while building an `OpaResolver` from its config block. -#[derive(Debug, Error)] -pub enum BuildError { - /// The config block is not shaped as expected (not a mapping, unknown key, - /// wrong value type for a known key). - #[error("invalid OPA PDP config: {0}")] - ConfigShape(String), - - /// A `module_files` entry could not be read from disk. - #[error("failed to read OPA module file '{path}': {source}")] - ModuleFile { - path: String, - #[source] - source: std::io::Error, - }, - - /// A Rego module (inline or from a file) failed to parse/compile. This is - /// an author bug surfaced at load time so it never reaches evaluation. The - /// regorus cause is stringified (its error is a type-erased `anyhow::Error`, - /// so there is no variant to carry). - #[error("failed to load Rego module '{name}': {cause}")] - ModuleParse { name: String, cause: String }, - - /// A `data_files` entry could not be read from disk. - #[error("failed to read OPA data file '{path}': {source}")] - DataFile { - path: String, - #[source] - source: std::io::Error, - }, - - /// A `data` document (inline or from a file) could not be parsed or merged - /// into the engine's `data` root. - #[error("failed to load OPA data '{name}': {cause}")] - DataParse { name: String, cause: String }, -} diff --git a/builtins/pdps/opa/src/factory.rs b/builtins/pdps/opa/src/factory.rs deleted file mode 100644 index a4c19b14..00000000 --- a/builtins/pdps/opa/src/factory.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Location: ./builtins/pdps/opa/src/factory.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// `OpaPdpFactory` — the `PdpFactory` the apl-cpex visitor uses to instantiate -// an `OpaResolver` from a unified-config block: -// -// ```yaml -// global: -// apl: -// pdp: -// - kind: opa -// on_error: deny # optional; deny | allow, default deny -// max_cache_entries: 1024 # optional; cap on distinct inline modules -// modules: # global Rego modules (inline and/or files) -// - | -// package authz -// default allow := false -// allow if input.subject.id == "alice" -// ``` -// -// The per-route query (and any inline module) lives in each route's -// `opa: { query: "data.authz.allow" }` step, not in this block. Hosts register -// an instance in `AplOptions.pdp_factories`; the visitor matches it by `kind`. - -use std::sync::Arc; - -use apl_core::step::{PdpFactory, PdpResolver}; - -use crate::resolver::OpaResolver; - -/// Factory for `OpaResolver`. Reports `kind() = "opa"`; builds resolvers from -/// the unified-config block via [`OpaResolver::from_config`]. -#[derive(Default)] -pub struct OpaPdpFactory; - -impl OpaPdpFactory { - pub fn new() -> Self { - Self - } -} - -impl PdpFactory for OpaPdpFactory { - fn kind(&self) -> &str { - "opa" - } - - fn build( - &self, - config: &serde_yaml::Value, - ) -> Result, Box> { - let resolver = OpaResolver::from_config(config)?; - Ok(Arc::new(resolver)) - } -} diff --git a/builtins/pdps/opa/src/input.rs b/builtins/pdps/opa/src/input.rs deleted file mode 100644 index 3829996e..00000000 --- a/builtins/pdps/opa/src/input.rs +++ /dev/null @@ -1,245 +0,0 @@ -// Location: ./builtins/pdps/opa/src/input.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Bag → Rego `input` mapping. -// -// APL's `AttributeBag` is a flat `HashMap` with dotted -// keys (`subject.id`, `role.hr`, `delegation.depth`). Rego wants a nested -// document so `input.subject.id` reads as field selection. This module rebuilds -// the flat bag into a nested JSON object that becomes the engine's `input`. -// -// This mirrors the tree-building and type coercions in `cpex-pdp-cel`'s -// `activation.rs` so a policy author's mental model of the attribute -// vocabulary is identical across the two backends. It is ported rather than -// shared so this crate stays apl-core-only at compile time. -// -// Type mapping (`AttributeValue` → JSON): -// Bool → bool -// Int → integer -// Float → integer when it is a whole number in i64 range, else float -// String → string -// StringSet → array of strings, sorted (so `input.x[0]` is deterministic) -// -// Collision rule: if a key is both a leaf and a namespace prefix (`delegation` -// AND `delegation.depth`), the namespace (object) wins and the scalar leaf is -// dropped with a `tracing::warn!`, matching the CEL resolver. - -use std::collections::BTreeMap; - -use apl_core::attributes::{AttributeBag, AttributeValue}; -use serde_json::{Map, Number, Value}; - -/// Build the Rego `input` document from the policy bag. -/// -/// Every dotted bag key becomes a nested field: `subject.id` → `{"subject": -/// {"id": ...}}`. Single-segment keys (`authenticated`) become top-level -/// fields. The result is always a JSON object (an empty bag yields `{}`). -pub fn bag_to_input(bag: &AttributeBag) -> Value { - let mut root: BTreeMap = BTreeMap::new(); - for (key, value) in bag.iter() { - let segments: Vec<&str> = key.split('.').collect(); - insert(&mut root, key, &segments, attr_to_value(value)); - } - node_map_to_value(root) -} - -/// Internal tree node: either a leaf scalar/array or a nested namespace. -enum Node { - Leaf(Value), - Branch(BTreeMap), -} - -/// Insert a leaf at the dotted path, creating intermediate branches. -/// Namespace-wins on leaf/branch collisions (see module docs). -fn insert(level: &mut BTreeMap, full_key: &str, segments: &[&str], leaf: Value) { - // `bag.iter()` never yields empty keys today, but return cleanly rather - // than panic if a future bag implementation emits one. - let Some((head, rest)) = segments.split_first() else { - return; - }; - let head = (*head).to_string(); - - if rest.is_empty() { - match level.get(&head) { - Some(Node::Branch(_)) => { - tracing::warn!( - key = %full_key, - "OPA input: scalar key collides with an existing namespace; \ - keeping the namespace and dropping the scalar" - ); - }, - _ => { - level.insert(head, Node::Leaf(leaf)); - }, - } - return; - } - - let entry = level - .entry(head) - .or_insert_with(|| Node::Branch(BTreeMap::new())); - if let Node::Leaf(_) = entry { - tracing::warn!( - key = %full_key, - "OPA input: namespace prefix collides with an existing scalar; \ - promoting to a namespace and dropping the scalar" - ); - *entry = Node::Branch(BTreeMap::new()); - } - if let Node::Branch(child) = entry { - insert(child, full_key, rest, leaf); - } -} - -/// Recursively convert a tree of nodes into a JSON object value. -fn node_map_to_value(children: BTreeMap) -> Value { - let mut map = Map::new(); - for (k, child) in children { - map.insert(k, node_to_value(child)); - } - Value::Object(map) -} - -fn node_to_value(node: Node) -> Value { - match node { - Node::Leaf(v) => v, - Node::Branch(children) => node_map_to_value(children), - } -} - -/// Convert one `AttributeValue` to a JSON value. -fn attr_to_value(attr: &AttributeValue) -> Value { - match attr { - AttributeValue::Bool(b) => Value::Bool(*b), - AttributeValue::Int(i) => Value::Number((*i).into()), - AttributeValue::Float(f) => float_to_value(*f), - AttributeValue::String(s) => Value::String(s.clone()), - // StringSet → sorted array. Rego treats the value as a JSON array; - // sorting makes any index-dependent policy deterministic across runs. - AttributeValue::StringSet(set) => { - let mut sorted: Vec<&String> = set.iter().collect(); - sorted.sort(); - Value::Array( - sorted - .into_iter() - .map(|s| Value::String(s.clone())) - .collect(), - ) - }, - } -} - -/// Yield an `f64` as a JSON integer when it is a whole number in `i64` range, -/// otherwise a JSON float. Keeps parity with CEL's `float_to_value` so a bag -/// value populated as `Float(2.0)` reads as `2` for an author. A non-finite -/// float has no JSON representation and becomes `null`. -fn float_to_value(f: f64) -> Value { - if f.is_finite() && f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 { - Value::Number((f as i64).into()) - } else { - Number::from_f64(f) - .map(Value::Number) - .unwrap_or(Value::Null) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - - use regorus::Engine; - - /// Evaluate a boolean Rego expression against an input built from `bag`. - /// Wraps the expression in a rule so we exercise the exact input path the - /// resolver uses (`set_input_json` + `eval_rule`). - fn rego_eval(expr: &str, bag: &AttributeBag) -> bool { - let input = bag_to_input(bag); - let mut engine = Engine::new(); - engine - .add_policy( - "t.rego".to_string(), - format!("package t\nresult if {{ {expr} }}\n"), - ) - .unwrap(); - engine.set_input_json(&input.to_string()).unwrap(); - engine - .eval_rule("data.t.result".to_string()) - .unwrap() - .as_bool() - .copied() - .unwrap_or(false) - } - - #[test] - fn dotted_keys_become_nested_fields() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - bag.set("subject.type", "user"); - assert!(rego_eval("input.subject.id == \"alice\"", &bag)); - assert!(rego_eval("input.subject.type == \"user\"", &bag)); - } - - #[test] - fn single_segment_key_is_top_level_field() { - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - assert!(rego_eval("input.authenticated == true", &bag)); - } - - #[test] - fn bool_int_float_string_scalars() { - let mut bag = AttributeBag::new(); - bag.set("role.hr", true); - bag.set("delegation.depth", 2_i64); - bag.set("intent.confidence", 0.92_f64); - bag.set("subject.id", "alice"); - assert!(rego_eval("input.role.hr == true", &bag)); - assert!(rego_eval("input.delegation.depth == 2", &bag)); - assert!(rego_eval("input.intent.confidence > 0.9", &bag)); - assert!(rego_eval("input.subject.id == \"alice\"", &bag)); - } - - #[test] - fn whole_number_float_reads_as_integer() { - let mut bag = AttributeBag::new(); - bag.set("delegation.depth", 2.0_f64); - // Emitted as the JSON integer 2 (not 2.0), so an author comparing to an - // integer literal gets the natural result. - assert_eq!( - bag_to_input(&bag)["delegation"]["depth"], - serde_json::json!(2) - ); - assert!(rego_eval("input.delegation.depth == 2", &bag)); - } - - #[test] - fn string_set_becomes_sorted_array() { - let mut bag = AttributeBag::new(); - bag.set( - "session.labels", - HashSet::from(["zeta".to_string(), "alpha".to_string(), "mu".to_string()]), - ); - assert!(rego_eval("\"alpha\" in input.session.labels", &bag)); - assert!(rego_eval("input.session.labels[0] == \"alpha\"", &bag)); - assert!(rego_eval("input.session.labels[2] == \"zeta\"", &bag)); - } - - #[test] - fn namespace_wins_on_leaf_collision() { - let mut bag = AttributeBag::new(); - bag.set("delegation", "scalar-value"); - bag.set("delegation.depth", 3_i64); - // The namespace must win so `delegation.depth` resolves rather than the - // scalar shadowing it. - assert!(rego_eval("input.delegation.depth == 3", &bag)); - } - - #[test] - fn empty_bag_is_empty_object() { - let bag = AttributeBag::new(); - assert_eq!(bag_to_input(&bag), serde_json::json!({})); - } -} diff --git a/builtins/pdps/opa/src/lib.rs b/builtins/pdps/opa/src/lib.rs deleted file mode 100644 index d6473eec..00000000 --- a/builtins/pdps/opa/src/lib.rs +++ /dev/null @@ -1,222 +0,0 @@ -// Location: ./builtins/pdps/opa/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// cpex-pdp-opa — `PdpResolver` over Microsoft's pure-Rust `regorus` Rego -// interpreter. -// -// # Where this lives in the stack -// -// APL evaluator (apl-core) -// │ `opa: { query: "data.authz.allow" }` step -// ▼ -// PdpRouter (apl-cpex) — dispatches by dialect (PdpDialect::Opa) -// │ resolver.evaluate(call, bag) -// ▼ -// OpaResolver — THIS CRATE -// │ bag → Rego input, clone base engine, eval query -// ▼ -// regorus::Engine — embedded Rego evaluation, no sidecar -// -// # Policy source (hybrid) -// -// Rego modules and `data` are declared in the `global.pdp` block and parsed -// once at factory-build time; a route step may also carry an inline `module`. -// See `resolver::OpaResolver::from_config` for the config shape and -// `factory::OpaPdpFactory` for how the visitor builds it. -// -// # The attribute vocabulary (bag → input) -// -// APL's flat, dotted `AttributeBag` (`subject.id`, `delegation.depth`, -// `session.labels`) is rebuilt into a nested Rego `input` document so authors -// write `input.subject.id`. See `input::bag_to_input` — the mapping mirrors -// the CEL resolver's so the vocabulary is identical across backends. -// -// # Decision contract -// -// The step's `query` must resolve to a boolean, a decision object (whose -// allow/deny bit is read from `decision_field`, default `allow`), or a -// set/array (the deny-set idiom: empty → allow, non-empty → deny). An -// undefined result is a clean deny — Rego's idiomatic "not granted" — never -// routed through `on_error`. A value carrying no decision, or a genuine eval -// error, is governed by `on_error` (default `deny`). A Rego parse/compile -// error always denies. See `decision::map_query_result`. -// -// # Evaluation model -// -// A base `regorus::Engine` holds the compiled global policy + data. Because -// the `arc` feature makes that state `Arc`-shared, the resolver clones the base -// per request (cheap), sets the request `input`, and evaluates — no lock on the -// hot path. Inline modules get a bounded prepared-engine cache. See -// `resolver` for the details. - -pub mod decision; -pub mod error; -pub mod factory; -pub mod input; -pub mod resolver; - -pub use error::BuildError; -pub use factory::OpaPdpFactory; -pub use resolver::{OnError, OpaResolver}; - -#[cfg(test)] -mod regorus_api_smoke { - // Pins the exact regorus 0.11 API the resolver is built on: incremental - // module load, JSON input, query eval, and the Value shapes for allow / - // deny-set / undefined. If a regorus upgrade changes any of these, this - // fails loudly here rather than deep in the resolver. - use regorus::Engine; - - #[test] - fn bool_query_allow_and_deny() { - let mut engine = Engine::new(); - engine - .add_policy( - "authz.rego".to_string(), - r#"package authz -default allow := false -allow if input.subject.id == "alice" -"# - .to_string(), - ) - .unwrap(); - - engine - .set_input_json(r#"{"subject":{"id":"alice"}}"#) - .unwrap(); - let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); - assert_eq!(v.as_bool().copied().ok(), Some(true)); - - // Non-match with a `default` → false (not undefined). - engine - .set_input_json(r#"{"subject":{"id":"eve"}}"#) - .unwrap(); - let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); - assert_eq!(v.as_bool().copied().ok(), Some(false)); - } - - #[test] - fn undefined_when_no_default_and_no_match() { - let mut engine = Engine::new(); - engine - .add_policy( - "authz.rego".to_string(), - r#"package authz -allow if input.subject.id == "alice" -"# - .to_string(), - ) - .unwrap(); - engine - .set_input_json(r#"{"subject":{"id":"eve"}}"#) - .unwrap(); - let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); - // No matching rule and no `default` → undefined, distinct from false. - assert!(matches!(v, regorus::Value::Undefined), "got {v:?}"); - } - - #[test] - fn deny_set_idiom_yields_set() { - let mut engine = Engine::new(); - engine - .add_policy( - "authz.rego".to_string(), - r#"package authz -deny contains msg if { - input.subject.id != "alice" - msg := "subject not allowed" -} -"# - .to_string(), - ) - .unwrap(); - - // Violating input → non-empty set. - engine - .set_input_json(r#"{"subject":{"id":"eve"}}"#) - .unwrap(); - let v = engine.eval_rule("data.authz.deny".to_string()).unwrap(); - match &v { - regorus::Value::Set(s) => assert_eq!(s.len(), 1), - other => panic!("expected Set, got {other:?}"), - } - - // Passing input → empty set. - engine - .set_input_json(r#"{"subject":{"id":"alice"}}"#) - .unwrap(); - let v = engine.eval_rule("data.authz.deny".to_string()).unwrap(); - match &v { - regorus::Value::Set(s) => assert!(s.is_empty()), - other => panic!("expected empty Set, got {other:?}"), - } - } - - #[test] - fn data_document_is_readable_by_policy() { - let mut engine = Engine::new(); - engine - .add_data_json(r#"{"roles":{"alice":["reader"]}}"#) - .unwrap(); - engine - .add_policy( - "authz.rego".to_string(), - r#"package authz -default allow := false -allow if "reader" in data.roles[input.subject.id] -"# - .to_string(), - ) - .unwrap(); - engine - .set_input_json(r#"{"subject":{"id":"alice"}}"#) - .unwrap(); - let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); - assert_eq!(v.as_bool().copied().ok(), Some(true)); - } - - #[test] - fn parse_error_surfaces_at_add_policy() { - let mut engine = Engine::new(); - let err = engine.add_policy("bad.rego".to_string(), "package x\nallow if {".to_string()); - assert!(err.is_err(), "malformed Rego must fail at add_policy"); - } - - #[test] - fn clone_shares_compiled_policy_and_isolates_input() { - let mut base = Engine::new(); - base.add_policy( - "authz.rego".to_string(), - r#"package authz -default allow := false -allow if input.subject.id == "alice" -"# - .to_string(), - ) - .unwrap(); - - let mut a = base.clone(); - a.set_input_json(r#"{"subject":{"id":"alice"}}"#).unwrap(); - let mut b = base.clone(); - b.set_input_json(r#"{"subject":{"id":"eve"}}"#).unwrap(); - - assert_eq!( - a.eval_rule("data.authz.allow".to_string()) - .unwrap() - .as_bool() - .copied() - .ok(), - Some(true) - ); - assert_eq!( - b.eval_rule("data.authz.allow".to_string()) - .unwrap() - .as_bool() - .copied() - .ok(), - Some(false) - ); - } -} diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs deleted file mode 100644 index a4ed5ee4..00000000 --- a/builtins/pdps/opa/src/resolver.rs +++ /dev/null @@ -1,1153 +0,0 @@ -// Location: ./builtins/pdps/opa/src/resolver.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// `OpaResolver` — the `PdpResolver` implementation over regorus. -// -// # Build once, evaluate many -// -// `from_config` prepares a base `regorus::Engine` at factory-build time: it -// parses every global Rego module and loads every `data` document a single -// time. Because regorus (with the `arc` feature) reference-counts its compiled -// policy and data behind atomic `Arc`, cloning the base engine is cheap and -// shares that compiled state. Every `evaluate` call therefore clones the base, -// sets the request `input`, and evaluates — no lock on the hot path, no -// re-parse. (All regorus `set_input`/`eval_*` methods take `&mut self`, so a -// per-request clone is what makes concurrent evaluation possible at all.) -// -// Clone-per-request is input-isolated by regorus contract, not by luck. -// `Interpreter::clone` resets the rule-value and builtin memo maps to empty, and -// `Engine::eval_rule` calls `clean_internal_evaluation_state()` before every -// evaluation, clearing those maps plus `data` and the scope stack. A regorus -// change to either would break input isolation, so `concurrent_evaluation_is_correct` -// and `sequential_reuse_does_not_leak_prior_input` pin the observable behavior. -// -// Inline `opa: { module: "..." }` steps get their own bounded cache of -// prepared engines (base + that module), so a distinct inline module is parsed -// at most once. The cache follows the workspace "cap + reject + log, never -// evict" convention. -// -// # Inline-module trust boundary -// -// A route-step inline module and the operator's global modules are both -// operator-authored config, but they can be edited by different parties (a -// central security team owns `global.pdp`; an app team may own a route). To -// keep an inline module from silently overriding operator policy, an inline -// module whose Rego package collides with a global-module package is rejected -// fail-closed (always deny) rather than merged. Inline modules may therefore -// *add* new packages but cannot redefine or extend a global package's rules. -// (Global modules still merge with each other per Rego package semantics — -// they share one trust level.) -// -// # Decision contract -// -// The configured query must resolve to a boolean, a decision object, or a -// set/array (see `crate::decision`). Fail-closed by default: an evaluation -// error routes through `on_error` (default `Deny`); a Rego parse error, an -// inline/global package collision, and a cache-full rejection always deny -// regardless of `on_error` (a compile-time or resource condition must never -// fail open). - -use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; - -use async_trait::async_trait; -use regorus::Engine; - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; - -use crate::decision::{map_query_result, Mapped}; -use crate::error::BuildError; -use crate::input::bag_to_input; - -/// What to do when a query errors at runtime or yields a value that carries no -/// decision (a non-bool/object/set result, or a missing decision field). A -/// `false`/deny result and an undefined result are NOT governed by this — they -/// are legitimate denials, always honored. Parse/compile errors, inline/global -/// package collisions, and cache-full rejections are never governed by this -/// either: they always deny (an author bug, a trust-boundary violation, or a -/// resource limit must never flip to allow). Mirrors `cpex-pdp-cel`'s `OnError`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum OnError { - /// Fail-closed: a degenerate runtime outcome denies. The APL default. - #[default] - Deny, - /// Fail-open: a degenerate runtime outcome allows through. For advisory - /// checks layered behind a hard PDP; the resolver logs at `error!` on this - /// path so it is never silent. - Allow, -} - -/// Default upper bound on the inline-module cache. Inline modules are -/// author-supplied in route YAML, so the cache fills with the policy's static -/// set of distinct inline modules. 1024 is generous for any realistic policy -/// and small enough that a templating bug trips the cap before it balloons -/// memory. Mirrors `cpex-pdp-cel`'s cache cap. -pub const DEFAULT_MAX_CACHE_ENTRIES: usize = 1024; - -/// Virtual filename regorus uses for the query's inline module. Distinct from -/// the `global-.rego` names global modules load under, so an inline module -/// adds to the engine rather than replacing a global one. -pub(crate) const INLINE_MODULE_NAME: &str = "__inline__.rego"; - -#[derive(Debug)] -pub struct OpaResolver { - dialect: PdpDialect, - on_error: OnError, - /// The object field (or, for a set/array result, ignored) that carries the - /// allow/deny boolean when the query resolves to an object. Default - /// `"allow"`. - decision_field: String, - /// The base engine, prepared once with all global modules + data. Cloned - /// per request (cheap — compiled policy/data is `Arc`-shared). - base_engine: Engine, - /// Rego package paths declared by the global modules (e.g. `data.authz`), - /// captured at build. A route-step inline module whose package is in this - /// set is rejected fail-closed so it cannot override operator policy. - global_packages: HashSet, - /// Cache of prepared engines for inline modules, keyed by module source. - /// `RwLock` so the steady-state read path is uncontended once a route's - /// inline module has been prepared. - inline_cache: RwLock>, - /// Upper bound on `inline_cache`. New entries past this are rejected (never - /// evicted), per the workspace cache convention. - max_cache_entries: usize, -} - -impl OpaResolver { - /// Build a resolver from a unified-config block. Shape: - /// - /// ```yaml - /// kind: opa # matched by the factory, not read here - /// on_error: deny # optional; deny | allow, default deny - /// decision_field: allow # optional; object field holding the bool, default "allow" - /// modules: # optional; inline Rego module texts - /// - | - /// package authz - /// default allow := false - /// allow if input.subject.id == "alice" - /// module_files: # optional; paths to Rego module files - /// - policies/authz.rego - /// data: # optional; inline data merged into the `data` root - /// roles: - /// alice: [reader] - /// data_files: # optional; paths to JSON/YAML data files - /// - data/roles.json - /// max_cache_entries: 1024 # optional; cap on distinct inline modules, - /// # default 1024. 0 disables inline modules (a - /// # step carrying one denies); global-module - /// # steps are unaffected. - /// ``` - /// - /// Global modules and data are parsed/loaded here, once. A Rego parse error - /// or a data merge conflict surfaces as a `BuildError` at load time. - pub fn from_config(value: &serde_yaml::Value) -> Result { - let map = value - .as_mapping() - .ok_or_else(|| BuildError::ConfigShape("OPA PDP config must be a mapping".into()))?; - - // Reject unknown keys so a typo fails loud at load rather than being - // silently dropped. `kind` is consumed by the factory but present here. - const KNOWN_KEYS: &[&str] = &[ - "kind", - "on_error", - "decision_field", - "modules", - "module_files", - "data", - "data_files", - "max_cache_entries", - ]; - for (key, _) in map { - let Some(name) = key.as_str() else { - return Err(BuildError::ConfigShape( - "OPA PDP config keys must be strings".into(), - )); - }; - if !KNOWN_KEYS.contains(&name) { - return Err(BuildError::ConfigShape(format!( - "unknown OPA PDP config key `{name}`; expected one of {KNOWN_KEYS:?}" - ))); - } - } - - let on_error = match read_string(map, "on_error")?.as_deref() { - None | Some("deny") => OnError::Deny, - Some("allow") => OnError::Allow, - Some(other) => { - return Err(BuildError::ConfigShape(format!( - "`on_error` must be `deny` or `allow`, got `{other}`" - ))); - }, - }; - - let decision_field = read_string(map, "decision_field")?.unwrap_or_else(|| "allow".into()); - - let mut engine = Engine::new(); - // Track each global module's Rego package (the path `add_policy` - // returns) so an inline step module can be rejected if it collides. - let mut global_packages = HashSet::new(); - - // 1. Global modules — inline texts first, then files. Each gets a - // unique virtual name so same-package modules merge (Rego - // semantics) rather than one overwriting another by filename. - for (module_index, text) in read_string_seq(map, "modules")?.into_iter().enumerate() { - let name = format!("global-{module_index}.rego"); - let package = - engine - .add_policy(name.clone(), text) - .map_err(|e| BuildError::ModuleParse { - name, - cause: e.to_string(), - })?; - global_packages.insert(package); - } - for path in read_string_seq(map, "module_files")? { - let text = std::fs::read_to_string(&path).map_err(|source| BuildError::ModuleFile { - path: path.clone(), - source, - })?; - let package = - engine - .add_policy(path.clone(), text) - .map_err(|e| BuildError::ModuleParse { - name: path, - cause: e.to_string(), - })?; - global_packages.insert(package); - } - - // 2. Data documents — inline mapping first, then files. Both are - // normalized to JSON (serde_yaml parses JSON too, so a `.json` or - // `.yaml` data file both work) and merged into the `data` root. - if let Some(data) = map.get(serde_yaml::Value::String("data".into())) { - if !data.is_null() { - merge_data(&mut engine, "data", data)?; - } - } - for path in read_string_seq(map, "data_files")? { - let text = std::fs::read_to_string(&path).map_err(|source| BuildError::DataFile { - path: path.clone(), - source, - })?; - let parsed: serde_yaml::Value = - serde_yaml::from_str(&text).map_err(|e| BuildError::DataParse { - name: path.clone(), - cause: e.to_string(), - })?; - merge_data(&mut engine, &path, &parsed)?; - } - - Ok(Self { - dialect: PdpDialect::Opa, - on_error, - decision_field, - base_engine: engine, - global_packages, - inline_cache: RwLock::new(HashMap::new()), - max_cache_entries: read_usize(map, "max_cache_entries")? - .unwrap_or(DEFAULT_MAX_CACHE_ENTRIES), - }) - } - - /// Override the resolver's dialect. Lets an operator register an OPA engine - /// under a custom name so two OPA resolvers can coexist on one router. - pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { - self.dialect = dialect; - self - } - - /// Override the inline-module cache cap (default - /// [`DEFAULT_MAX_CACHE_ENTRIES`]). Equivalent to the `max_cache_entries` - /// config key, for hosts constructing a resolver in Rust. - pub fn with_max_cache_entries(mut self, max_cache_entries: usize) -> Self { - self.max_cache_entries = max_cache_entries; - self - } - - /// Get an engine ready to evaluate this step: the base engine when the step - /// carries no inline module, or a cached base+module engine otherwise. The - /// returned engine is a fresh clone the caller mutates (set input, eval) - /// without touching the shared base or cache. Cloning is cheap — regorus - /// (`arc`) `Arc`-shares the compiled policy and data. - fn engine_for(&self, module: Option<&str>) -> Result { - let Some(src) = module else { - return Ok(self.base_engine.clone()); - }; - - // Fast path: this inline module was already prepared. - if let Some(engine) = self - .inline_cache - .read() - .unwrap_or_else(|p| p.into_inner()) - .get(src) - { - return Ok(engine.clone()); - } - - // Prepare base + inline module. A parse failure here is a compile error - // (always denies), not a runtime condition. - // - // A cold-start race can have two threads prepare the same module at - // once. The second insert overwrites an equivalent engine, so the only - // cost is a duplicated compile on first hit. That is deliberate: - // holding the write lock across `add_policy` would serialize all - // inline-module preparation. The cap check below is race-safe because - // the length check and the insert share one write-lock acquisition, and - // the `contains_key` guard keeps a same-key racer from being rejected - // once the cache is full. - let mut engine = self.base_engine.clone(); - let package = engine - .add_policy(INLINE_MODULE_NAME.to_string(), src.to_string()) - .map_err(|e| EngineError::Compile(e.to_string()))?; - - // Reject an inline module that lands in a global module's package — it - // would merge into (and could override) operator policy. Fail-closed: - // inline modules may add new packages, never redefine a global one. - if self.global_packages.contains(&package) { - return Err(EngineError::PackageCollision(package)); - } - - // Insert under the cap — reject past it, never evict (workspace cache - // convention). - let mut cache = self.inline_cache.write().unwrap_or_else(|p| p.into_inner()); - if cache.len() >= self.max_cache_entries && !cache.contains_key(src) { - tracing::warn!( - cap = self.max_cache_entries, - "OPA inline-module cache full; rejecting new module. Existing entries are not \ - evicted. Raise `max_cache_entries` in the OPA PDP config block if the policy \ - legitimately needs more distinct inline modules." - ); - return Err(EngineError::CacheFull { - cap: self.max_cache_entries, - }); - } - cache.insert(src.to_string(), engine.clone()); - Ok(engine) - } - - /// Apply `on_error` to a degenerate RUNTIME outcome (an eval error or a - /// value carrying no decision). Allow logs at `error!` so a misused - /// fail-open flag is never silent in production. (Compile errors, package - /// collisions, and cache-full rejections do NOT come through here — they - /// always deny via `compile_error_decision`.) - fn on_error_decision(&self, cause: String) -> PdpDecision { - match self.on_error { - OnError::Allow => { - tracing::error!( - cause = %cause, - "OPA runtime error; on_error=allow → allowing through. \ - This is fail-open behavior; verify it is intentional." - ); - PdpDecision { - decision: Decision::Allow, - diagnostics: vec![cause], - } - }, - OnError::Deny => PdpDecision { - decision: Decision::Deny { - reason: Some(cause.clone()), - rule_source: "opa".to_string(), - }, - diagnostics: vec![cause], - }, - } - } - - /// Always deny, regardless of `on_error`, for a non-negotiable failure: a - /// Rego compile error (author bug), an inline/global package collision - /// (trust-boundary violation), or a cache-full rejection (resource limit). - /// None of these is a policy outcome that may fail open. - fn compile_error_decision(&self, cause: String) -> PdpDecision { - tracing::error!( - cause = %cause, - "OPA fail-closed condition — denying the request regardless of on_error mode." - ); - PdpDecision { - decision: Decision::Deny { - reason: Some(cause.clone()), - rule_source: "opa".to_string(), - }, - diagnostics: vec![cause], - } - } -} - -/// Internal — failure shapes from preparing a per-step engine. All three -/// always deny regardless of `on_error`: a compile error is an author bug, a -/// package collision is a trust-boundary violation, and a cache-full condition -/// is a resource limit — none is a legitimate policy outcome that should be -/// allowed to fail open. -enum EngineError { - /// A Rego parse/compile error in an inline module. - Compile(String), - /// The inline module's package collides with a global-module package. - PackageCollision(String), - /// The inline-module cache hit its cap. - CacheFull { cap: usize }, -} - -/// Outcome of the blocking evaluation task. Held as an owned, `Send` value so -/// it can cross the `spawn_blocking` boundary; `on_error` is applied by the -/// caller (which needs `&self` for logging). -enum EvalOutcome { - /// A terminal decision the contract produced directly. - Decision(PdpDecision), - /// A degenerate runtime outcome (eval error, non-decision value) whose - /// disposition depends on `on_error`. - OnError(String), -} - -#[async_trait] -impl PdpResolver for OpaResolver { - fn dialect(&self) -> PdpDialect { - self.dialect.clone() - } - - async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result { - // 1. Required `query` and optional inline `module` from the step args. - // A missing `query` is an author/config bug — hard error. - let args = call.args.as_mapping(); - let query = args - .and_then(|m| m.get(serde_yaml::Value::String("query".into()))) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - PdpError::Dispatch("opa: step requires a string `query` argument".to_string()) - })?; - let module = args - .and_then(|m| m.get(serde_yaml::Value::String("module".into()))) - .and_then(|v| v.as_str()); - - // 2. Prepare the engine. A compile error, a package collision, and a - // cache-full rejection all always deny — none may fail open. - let engine = match self.engine_for(module) { - Ok(engine) => engine, - Err(EngineError::Compile(cause)) => { - return Ok(self.compile_error_decision(format!("OPA inline module: {cause}"))); - }, - Err(EngineError::PackageCollision(package)) => { - return Ok(self.compile_error_decision(format!( - "OPA inline module package `{package}` collides with a global-module \ - package; inline modules may not override global policy" - ))); - }, - Err(EngineError::CacheFull { cap }) => { - return Ok(self.compile_error_decision(format!( - "OPA inline-module cache full (cap={cap}); refusing a new module" - ))); - }, - }; - - // 3. Map the bag into the Rego `input` document, then set input and - // evaluate on a blocking thread — Rego eval is synchronous and can - // be CPU-heavy, and must not monopolize an async worker. - let input_json = bag_to_input(bag).to_string(); - let query = query.to_string(); - let decision_field = self.decision_field.clone(); - let outcome = tokio::task::spawn_blocking(move || { - let mut engine = engine; - if let Err(e) = engine.set_input_json(&input_json) { - return EvalOutcome::OnError(format!("OPA failed to set input: {e}")); - } - match engine.eval_rule(query) { - Ok(value) => match map_query_result(&value, &decision_field) { - Mapped::Decision(decision) => EvalOutcome::Decision(decision), - Mapped::Degenerate(cause) => EvalOutcome::OnError(cause), - }, - Err(e) => EvalOutcome::OnError(format!("OPA eval error: {e}")), - } - }) - .await; - - // 4. Apply on_error to genuine degenerate outcomes (eval error, - // non-decision value). A task panic/abort is an abnormal - // termination, not a policy outcome, so it always denies regardless - // of on_error — the same fail-closed treatment as compile errors. - match outcome { - Ok(EvalOutcome::Decision(decision)) => Ok(decision), - Ok(EvalOutcome::OnError(cause)) => Ok(self.on_error_decision(cause)), - Err(join_err) => Ok( - self.compile_error_decision(format!("OPA evaluation task panicked: {join_err}")) - ), - } - } -} - -/// Normalize a YAML/JSON data document to JSON and merge it into the engine's -/// `data` root. `name` labels the source (`"data"` or a file path) in errors. -fn merge_data( - engine: &mut Engine, - name: &str, - value: &serde_yaml::Value, -) -> Result<(), BuildError> { - let to_err = |cause: String| BuildError::DataParse { - name: name.to_string(), - cause, - }; - let json = serde_json::to_string(value).map_err(|e| to_err(e.to_string()))?; - engine - .add_data_json(&json) - .map_err(|e| to_err(e.to_string()))?; - Ok(()) -} - -/// Read an optional string field from a YAML mapping. A key that is absent (or -/// explicitly null) yields `None`; a key present with a non-string value is a -/// config error rather than a silent default, matching the strictness applied -/// to unknown keys and non-sequence `modules`. -fn read_string(map: &serde_yaml::Mapping, key: &str) -> Result, BuildError> { - match map.get(serde_yaml::Value::String(key.to_string())) { - None => Ok(None), - Some(serde_yaml::Value::Null) => Ok(None), - Some(value) => match value.as_str() { - Some(s) => Ok(Some(s.to_string())), - None => Err(BuildError::ConfigShape(format!("`{key}` must be a string"))), - }, - } -} - -/// Read an optional non-negative integer field. A key that is absent (or -/// explicitly null) yields `None`; a present value that is not a non-negative -/// integer is a config error rather than a silent default, matching the -/// strictness applied to the string fields. -fn read_usize(map: &serde_yaml::Mapping, key: &str) -> Result, BuildError> { - match map.get(serde_yaml::Value::String(key.to_string())) { - None | Some(serde_yaml::Value::Null) => Ok(None), - Some(value) => value - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .map(Some) - .ok_or_else(|| { - BuildError::ConfigShape(format!("`{key}` must be a non-negative integer")) - }), - } -} - -/// Read an optional sequence-of-strings field. A missing key yields an empty -/// vec; a present-but-non-sequence value, or a non-string element, is a config -/// error. -fn read_string_seq(map: &serde_yaml::Mapping, key: &str) -> Result, BuildError> { - let Some(value) = map.get(serde_yaml::Value::String(key.to_string())) else { - return Ok(Vec::new()); - }; - if value.is_null() { - return Ok(Vec::new()); - } - let seq = value - .as_sequence() - .ok_or_else(|| BuildError::ConfigShape(format!("`{key}` must be a sequence of strings")))?; - seq.iter() - .map(|item| { - item.as_str() - .map(|s| s.to_string()) - .ok_or_else(|| BuildError::ConfigShape(format!("`{key}` entries must be strings"))) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cfg(yaml: &str) -> Result { - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - OpaResolver::from_config(&value) - } - - #[test] - fn builds_from_inline_module_and_data() { - let r = cfg(r#" -kind: opa -on_error: deny -modules: - - | - package authz - default allow := false - allow if input.subject.id == "alice" -data: - roles: - alice: [reader] -"#) - .expect("should build"); - assert_eq!(r.on_error, OnError::Deny); - assert_eq!(r.decision_field, "allow"); - } - - #[test] - fn on_error_allow_parses() { - let r = cfg("kind: opa\non_error: allow\n").unwrap(); - assert_eq!(r.on_error, OnError::Allow); - } - - #[test] - fn on_error_bad_value_rejected() { - let err = cfg("kind: opa\non_error: maybe\n").unwrap_err(); - assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("on_error"))); - } - - #[test] - fn decision_field_override_read() { - let r = cfg("kind: opa\ndecision_field: permit\nmodules:\n - \"package p\"\n").unwrap(); - assert_eq!(r.decision_field, "permit"); - } - - #[test] - fn max_cache_entries_read_from_config() { - let r = cfg("kind: opa\nmax_cache_entries: 7\n").unwrap(); - assert_eq!(r.max_cache_entries, 7); - } - - #[test] - fn max_cache_entries_defaults_when_absent() { - let r = cfg("kind: opa\n").unwrap(); - assert_eq!(r.max_cache_entries, DEFAULT_MAX_CACHE_ENTRIES); - } - - #[test] - fn non_integer_max_cache_entries_is_rejected() { - let err = cfg("kind: opa\nmax_cache_entries: many\n").unwrap_err(); - assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("max_cache_entries"))); - } - - #[test] - fn negative_max_cache_entries_is_rejected() { - let err = cfg("kind: opa\nmax_cache_entries: -1\n").unwrap_err(); - assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("max_cache_entries"))); - } - - #[test] - fn unknown_key_rejected_naming_the_key() { - let err = cfg("kind: opa\non_errr: allow\n").unwrap_err(); - match err { - BuildError::ConfigShape(m) => assert!(m.contains("on_errr"), "got {m}"), - other => panic!("expected ConfigShape, got {other:?}"), - } - } - - #[test] - fn rego_parse_error_surfaces_at_build() { - let err = cfg("kind: opa\nmodules:\n - \"package x\\nallow if {\"\n").unwrap_err(); - assert!(matches!(err, BuildError::ModuleParse { .. }), "got {err:?}"); - } - - #[test] - fn missing_module_file_names_the_path() { - let err = cfg("kind: opa\nmodule_files:\n - /no/such/authz.rego\n").unwrap_err(); - match err { - BuildError::ModuleFile { path, .. } => assert_eq!(path, "/no/such/authz.rego"), - other => panic!("expected ModuleFile, got {other:?}"), - } - } - - #[test] - fn config_must_be_a_mapping() { - let value: serde_yaml::Value = serde_yaml::from_str("- just\n- a\n- list\n").unwrap(); - assert!(matches!( - OpaResolver::from_config(&value), - Err(BuildError::ConfigShape(_)) - )); - } - - #[test] - fn modules_must_be_a_sequence() { - let err = cfg("kind: opa\nmodules: not-a-list\n").unwrap_err(); - assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("modules"))); - } - - #[test] - fn non_string_on_error_is_rejected_not_silently_defaulted() { - // A non-string `on_error` (here a list) must fail loudly at load rather - // than silently falling back to the deny default. - let err = cfg("kind: opa\non_error: [deny]\n").unwrap_err(); - assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("on_error"))); - } - - #[test] - fn non_string_decision_field_is_rejected() { - let err = - cfg("kind: opa\ndecision_field: {a: 1}\nmodules:\n - \"package p\"\n").unwrap_err(); - assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("decision_field"))); - } - - /// A distinct temp file path per test, so parallel tests never collide. - fn temp_path(name: &str, ext: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!("cpex_opa_{}_{}.{ext}", std::process::id(), name)) - } - - #[test] - fn module_files_load_and_evaluate() { - let path = temp_path("modfile", "rego"); - std::fs::write( - &path, - "package authz\ndefault allow := false\nallow if input.subject.id == \"alice\"\n", - ) - .unwrap(); - let yaml = format!("kind: opa\nmodule_files:\n - {}\n", path.display()); - let r = cfg(&yaml).expect("module_files should load"); - // Prove the loaded rule actually evaluates. - let mut engine = r.base_engine.clone(); - engine - .set_input_json(r#"{"subject":{"id":"alice"}}"#) - .unwrap(); - assert_eq!( - engine - .eval_rule("data.authz.allow".to_string()) - .unwrap() - .as_bool() - .copied() - .ok(), - Some(true) - ); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn data_files_load_and_are_readable() { - let path = temp_path("datafile", "json"); - std::fs::write(&path, r#"{"roles":{"alice":["reader"]}}"#).unwrap(); - let yaml = format!( - "kind: opa\nmodules:\n - |\n package authz\n default allow := false\n allow if \"reader\" in data.roles[input.subject.id]\ndata_files:\n - {}\n", - path.display() - ); - let r = cfg(&yaml).expect("data_files should load"); - let mut engine = r.base_engine.clone(); - engine - .set_input_json(r#"{"subject":{"id":"alice"}}"#) - .unwrap(); - assert_eq!( - engine - .eval_rule("data.authz.allow".to_string()) - .unwrap() - .as_bool() - .copied() - .ok(), - Some(true) - ); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn missing_data_file_names_the_path() { - let err = cfg("kind: opa\ndata_files:\n - /no/such/roles.json\n").unwrap_err(); - match err { - BuildError::DataFile { path, .. } => assert_eq!(path, "/no/such/roles.json"), - other => panic!("expected DataFile, got {other:?}"), - } - } -} - -#[cfg(test)] -mod eval_tests { - use super::*; - use std::sync::Arc; - - /// Build a resolver from a set of inline global modules. - fn resolver(modules: &[&str], on_error: OnError) -> OpaResolver { - let mut map = serde_yaml::Mapping::new(); - map.insert(sv("kind"), sv("opa")); - if on_error == OnError::Allow { - map.insert(sv("on_error"), sv("allow")); - } - let mods = modules.iter().map(|m| sv(m)).collect(); - map.insert(sv("modules"), serde_yaml::Value::Sequence(mods)); - OpaResolver::from_config(&serde_yaml::Value::Mapping(map)).unwrap() - } - - fn sv(s: &str) -> serde_yaml::Value { - serde_yaml::Value::String(s.to_string()) - } - - /// Build a resolver from raw config YAML, for tests exercising config keys - /// the `resolver` helper does not set. - fn resolver_from_yaml(yaml: &str) -> OpaResolver { - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - OpaResolver::from_config(&value).unwrap() - } - - /// Build an `opa:` step call with a query and optional inline module. - fn call(query: &str, module: Option<&str>) -> PdpCall { - let mut m = serde_yaml::Mapping::new(); - m.insert(sv("query"), sv(query)); - if let Some(src) = module { - m.insert(sv("module"), sv(src)); - } - PdpCall { - dialect: PdpDialect::Opa, - args: serde_yaml::Value::Mapping(m), - } - } - - fn bag(subject_id: &str) -> AttributeBag { - let mut b = AttributeBag::new(); - b.set("subject.id", subject_id); - b - } - - const ALLOW_WITH_DEFAULT: &str = r#"package authz -default allow := false -allow if input.subject.id == "alice" -"#; - - const ALLOW_NO_DEFAULT: &str = r#"package authz -allow if input.subject.id == "alice" -"#; - - const DENY_SET: &str = r#"package authz -deny contains msg if { - input.subject.id != "alice" - msg := "subject not allowed" -} -"#; - - const DECISION_OBJECT: &str = r#"package authz -result := {"allow": input.subject.id == "alice"} -"#; - - const STRING_RESULT: &str = r#"package authz -msg := "not a decision" -"#; - - #[tokio::test] - async fn allow_when_policy_grants() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); - let out = r - .evaluate(&call("data.authz.allow", None), &bag("alice")) - .await - .unwrap(); - assert_eq!(out.decision, Decision::Allow); - } - - #[tokio::test] - async fn deny_when_policy_returns_false() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); - let out = r - .evaluate(&call("data.authz.allow", None), &bag("eve")) - .await - .unwrap(); - assert!(matches!(out.decision, Decision::Deny { .. })); - } - - /// Undefined (non-match with no `default`) is a clean deny even under - /// on_error: allow — it must never fail open. - #[tokio::test] - async fn undefined_denies_even_with_on_error_allow() { - let r = resolver(&[ALLOW_NO_DEFAULT], OnError::Allow); - let out = r - .evaluate(&call("data.authz.allow", None), &bag("eve")) - .await - .unwrap(); - match out.decision { - Decision::Deny { reason, .. } => { - assert!(reason.unwrap_or_default().contains("undefined")); - }, - other => panic!("undefined must deny, got {other:?}"), - } - } - - #[tokio::test] - async fn deny_set_empty_allows_nonempty_denies() { - let r = resolver(&[DENY_SET], OnError::Deny); - // Passing subject → empty deny set → allow. - let allow = r - .evaluate(&call("data.authz.deny", None), &bag("alice")) - .await - .unwrap(); - assert_eq!(allow.decision, Decision::Allow); - // Violating subject → non-empty deny set → deny with the message. - let deny = r - .evaluate(&call("data.authz.deny", None), &bag("eve")) - .await - .unwrap(); - assert!(matches!(deny.decision, Decision::Deny { .. })); - assert!(deny.diagnostics.iter().any(|d| d == "subject not allowed")); - } - - #[tokio::test] - async fn decision_object_allow_and_deny() { - let r = resolver(&[DECISION_OBJECT], OnError::Deny); - let allow = r - .evaluate(&call("data.authz.result", None), &bag("alice")) - .await - .unwrap(); - assert_eq!(allow.decision, Decision::Allow); - let deny = r - .evaluate(&call("data.authz.result", None), &bag("eve")) - .await - .unwrap(); - assert!(matches!(deny.decision, Decision::Deny { .. })); - } - - /// A value that carries no decision (a bare string) is degenerate → routes - /// through on_error: deny by default, allow when configured. - #[tokio::test] - async fn non_decision_value_routes_through_on_error() { - let deny_r = resolver(&[STRING_RESULT], OnError::Deny); - let deny = deny_r - .evaluate(&call("data.authz.msg", None), &bag("alice")) - .await - .unwrap(); - assert!(matches!(deny.decision, Decision::Deny { .. })); - - let allow_r = resolver(&[STRING_RESULT], OnError::Allow); - let allow = allow_r - .evaluate(&call("data.authz.msg", None), &bag("alice")) - .await - .unwrap(); - assert_eq!(allow.decision, Decision::Allow); - } - - /// An inline module with a Rego syntax error always denies, even under - /// on_error: allow — malformed policy never flips to allow. - #[tokio::test] - async fn inline_compile_error_always_denies() { - let r = resolver(&[], OnError::Allow); - let out = r - .evaluate( - &call("data.x.allow", Some("package x\nallow if {")), - &bag("alice"), - ) - .await - .unwrap(); - match out.decision { - Decision::Deny { reason, .. } => { - assert!(reason.unwrap_or_default().contains("inline module")); - }, - other => panic!("compile error must deny, got {other:?}"), - } - } - - /// An inline module whose package collides with a global-module package is - /// rejected fail-closed — it must not be able to merge into (and override) - /// operator policy, even under on_error: allow. - #[tokio::test] - async fn inline_module_cannot_override_global_package() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Allow); - // A hostile inline module tries to force allow in the operator's `authz` - // package. It shares the package, so it is rejected → deny. - let inline = "package authz\nallow if true\n"; - let out = r - .evaluate(&call("data.authz.allow", Some(inline)), &bag("eve")) - .await - .unwrap(); - match out.decision { - Decision::Deny { reason, .. } => { - assert!(reason.unwrap_or_default().contains("collides")); - }, - other => panic!("package collision must deny, got {other:?}"), - } - } - - /// An inline module in a fresh package (no global collision) is accepted and - /// evaluates — inline modules remain a usable feature for additive policy. - #[tokio::test] - async fn inline_module_in_new_package_is_allowed() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); - let inline = "package extra\nallow if input.subject.id == \"alice\"\n"; - let out = r - .evaluate(&call("data.extra.allow", Some(inline)), &bag("alice")) - .await - .unwrap(); - assert_eq!(out.decision, Decision::Allow); - } - - #[tokio::test] - async fn missing_query_is_dispatch_error() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); - let call = PdpCall { - dialect: PdpDialect::Opa, - args: serde_yaml::Value::Null, - }; - let err = r.evaluate(&call, &bag("alice")).await.unwrap_err(); - assert!(matches!(err, PdpError::Dispatch(_))); - } - - /// At the inline-module cache cap, a new distinct inline module is rejected - /// and routed through on_error; an already-cached module still evaluates. - #[tokio::test] - async fn inline_cache_cap_rejects_new_modules() { - let r = resolver(&[], OnError::Deny).with_max_cache_entries(1); - let m1 = "package a\nallow if input.subject.id == \"alice\"\n"; - let m2 = "package b\nallow if input.subject.id == \"alice\"\n"; - - // First inline module fills the cache and evaluates. - let first = r - .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) - .await - .unwrap(); - assert_eq!(first.decision, Decision::Allow); - - // Second distinct module → cap rejection → on_error deny. - let second = r - .evaluate(&call("data.b.allow", Some(m2)), &bag("alice")) - .await - .unwrap(); - assert!(matches!(second.decision, Decision::Deny { .. })); - assert!(second.diagnostics.iter().any(|d| d.contains("cache full"))); - - // Cached module still works. - let again = r - .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) - .await - .unwrap(); - assert_eq!(again.decision, Decision::Allow); - } - - /// The `max_cache_entries` config key must reach the resolver, not just the - /// Rust builder: an operator hitting the cap through unified config has no - /// other lever. - #[tokio::test] - async fn config_max_cache_entries_reaches_the_resolver() { - let r = resolver_from_yaml("kind: opa\nmax_cache_entries: 1\n"); - let m1 = "package a\nallow if input.subject.id == \"alice\"\n"; - let m2 = "package b\nallow if input.subject.id == \"alice\"\n"; - - let first = r - .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) - .await - .unwrap(); - assert_eq!(first.decision, Decision::Allow); - - let second = r - .evaluate(&call("data.b.allow", Some(m2)), &bag("alice")) - .await - .unwrap(); - assert!(matches!(second.decision, Decision::Deny { .. })); - assert!(second.diagnostics.iter().any(|d| d.contains("cache full"))); - } - - /// A cap of 0 is a lockdown stance, not a footgun: inline modules are - /// refused (and deny) while global-module steps keep evaluating, because a - /// step without an inline module never touches the cache. - #[tokio::test] - async fn zero_max_cache_entries_disables_inline_modules_only() { - const YAML: &str = r#"kind: opa -max_cache_entries: 0 -modules: - - | - package authz - default allow := false - allow if input.subject.id == "alice" -"#; - let r = resolver_from_yaml(YAML); - - let global = r - .evaluate(&call("data.authz.allow", None), &bag("alice")) - .await - .unwrap(); - assert_eq!(global.decision, Decision::Allow); - - let inline = "package extra\nallow := true\n"; - let out = r - .evaluate(&call("data.extra.allow", Some(inline)), &bag("alice")) - .await - .unwrap(); - assert!( - matches!(out.decision, Decision::Deny { .. }), - "an inline module must be refused at a cap of 0", - ); - } - - /// A cache-full rejection is a resource limit, not a policy outcome, so it - /// denies even under on_error: allow — it must not fail open. - #[tokio::test] - async fn inline_cache_cap_denies_even_under_on_error_allow() { - let r = resolver(&[], OnError::Allow).with_max_cache_entries(1); - let m1 = "package a\nallow if input.subject.id == \"alice\"\n"; - let m2 = "package b\nallow if input.subject.id == \"alice\"\n"; - let _ = r - .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) - .await - .unwrap(); - let out = r - .evaluate(&call("data.b.allow", Some(m2)), &bag("alice")) - .await - .unwrap(); - assert!( - matches!(out.decision, Decision::Deny { .. }), - "cache-full must deny even with on_error: allow", - ); - } - - /// Reusing one resolver across requests must not serve a prior request's - /// decision. Pins the regorus invariant the clone-per-request model rests - /// on: cloning an engine and evaluating both clear the interpreter's - /// input-derived memo state. - #[tokio::test] - async fn sequential_reuse_does_not_leak_prior_input() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); - for (subject, expected_allow) in [("alice", true), ("eve", false), ("alice", true)] { - let out = r - .evaluate(&call("data.authz.allow", None), &bag(subject)) - .await - .unwrap(); - if expected_allow { - assert_eq!(out.decision, Decision::Allow, "{subject} must be allowed"); - } else { - assert!( - matches!(out.decision, Decision::Deny { .. }), - "{subject} must be denied, got {:?}", - out.decision, - ); - } - } - } - - /// Many threads sharing one `Arc` evaluate concurrently and - /// each gets the correct per-request decision (exercises clone-per-request - /// under the `arc` feature). Together with - /// `sequential_reuse_does_not_leak_prior_input`, guards against a regorus - /// change that starts retaining input-derived state across evaluations. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_evaluation_is_correct() { - let r = Arc::new(resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny)); - let tasks: Vec<_> = (0..64) - .map(|i| { - let r = Arc::clone(&r); - tokio::spawn(async move { - let id = if i % 2 == 0 { "alice" } else { "eve" }; - let out = r - .evaluate(&call("data.authz.allow", None), &bag(id)) - .await - .unwrap(); - (id, out.decision) - }) - }) - .collect(); - for t in tasks { - let (id, decision) = t.await.unwrap(); - if id == "alice" { - assert_eq!(decision, Decision::Allow); - } else { - assert!(matches!(decision, Decision::Deny { .. })); - } - } - } - - #[test] - fn with_dialect_override_is_observable() { - let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny) - .with_dialect(PdpDialect::Custom("opa-strict".into())); - assert_eq!(r.dialect(), PdpDialect::Custom("opa-strict".into())); - } - - /// Security gate: the `http.send` builtin is excluded from the pinned - /// regorus feature set, so an inline policy attempting network egress must - /// not succeed — it fails to compile/evaluate and denies. Guards against a - /// regorus upgrade or feature-set change silently re-enabling egress. - #[tokio::test] - async fn disabled_http_builtin_cannot_allow() { - let r = resolver(&[], OnError::Deny); - let inline = "package net\nallow if http.send({\"method\": \"get\", \"url\": \"http://example.com\"})\n"; - let out = r - .evaluate(&call("data.net.allow", Some(inline)), &bag("alice")) - .await - .unwrap(); - assert!( - matches!(out.decision, Decision::Deny { .. }), - "http.send must be unavailable and deny, not allow", - ); - } -} diff --git a/builtins/pdps/opa/tests/visitor_opa_config.rs b/builtins/pdps/opa/tests/visitor_opa_config.rs deleted file mode 100644 index cc3b6e1c..00000000 --- a/builtins/pdps/opa/tests/visitor_opa_config.rs +++ /dev/null @@ -1,353 +0,0 @@ -// Location: ./builtins/pdps/opa/tests/visitor_opa_config.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// End-to-end integration: a unified-config YAML that -// -// 1. declares an `opa` PDP under `global.apl.pdp[]` with Rego module(s), -// 2. attaches an `opa: { query: "..." }` policy step to a route, -// -// must flow a real decision from the cpex-core dispatcher through -// `AplConfigVisitor` → `PdpFactory` → `OpaResolver` → the regorus engine → -// back into the route handler's allow/deny split. -// -// This proves the wiring end-to-end. The crate's unit tests cover the -// bag→input mapping, config parsing, and the decision contract in isolation; -// what's special here is that the resolver was never instantiated in Rust by -// the test — the visitor built it from YAML at `load_config_yaml` time because -// the host registered `OpaPdpFactory` via `AplOptions.pdp_factories`. If this -// passes, an operator who drops an `opa` block into their config gets the same -// behavior without writing any glue. - -use std::collections::HashSet; -use std::sync::Arc; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension, SubjectType}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; -use cpex_pdp_opa::OpaPdpFactory; - -// A boolean allow-rule policy declared globally; the route queries it. The bag -// the cmf BagBuilder lifts from the SecurityExtension exposes `subject.id`, -// which the policy reads as `input.subject.id`. -const YAML: &str = r#" -global: - apl: - pdp: - - kind: opa - modules: - - | - package authz - default allow := false - allow if input.subject.id == "alice" -routes: - - tool: get_document - apl: - pre_invocation: - - opa: - query: data.authz.allow -"#; - -fn meta_for_tool(name: &str) -> MetaExtension { - MetaExtension { - entity_type: Some("tool".to_string()), - entity_name: Some(name.to_string()), - ..Default::default() - } -} - -fn security_with_roles(id: &str, roles: &[&str]) -> SecurityExtension { - SecurityExtension { - subject: Some(SubjectExtension { - id: Some(id.to_string()), - subject_type: Some(SubjectType::User), - roles: roles.iter().map(|r| r.to_string()).collect::>(), - ..Default::default() - }), - ..Default::default() - } -} - -async fn build_manager() -> Arc { - build_manager_with_yaml(YAML) - .await - .expect("load_config_yaml") -} - -/// Build a manager from arbitrary YAML; returns the load error so negative -/// tests can inspect it. -async fn build_manager_with_yaml( - yaml: &str, -) -> Result, Box> { - let mgr = Arc::new(PluginManager::default()); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - // The factory is the load-bearing wiring under test: the visitor - // sees `kind: opa` in YAML and finds this factory by key. - pdp_factories: vec![Arc::new(OpaPdpFactory::new())], - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(yaml) - .map_err(|e| -> Box { format!("{e}").into() })?; - mgr.initialize() - .await - .map_err(|e| -> Box { format!("{e}").into() })?; - Ok(mgr) -} - -fn payload() -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, "fetch doc-42"), - } -} - -/// `alice` satisfies the Rego `allow` rule → the query is `true` → Allow. -/// End-to-end: the visitor built the resolver from YAML, the route handler -/// dispatched the `opa:` step into it, regorus returned `true`, the pipeline -/// continues. -#[tokio::test] -async fn config_declared_opa_pdp_allows_matching_subject() { - let mgr = build_manager().await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - result.continue_processing, - "alice should satisfy the Rego allow rule; got violation = {:?}", - result.violation - ); -} - -/// `eve` does not match; the `allow` rule has a `default` of false → the query -/// is `false` → Deny halts the pipeline with a violation. -#[tokio::test] -async fn config_declared_opa_pdp_denies_non_matching_subject() { - let mgr = build_manager().await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("eve", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "eve should fail the subject.id check and be denied", - ); - assert!( - result.violation.is_some(), - "deny path must surface a violation", - ); -} - -/// A malformed OPA PDP config (`on_error: maybe`) must be rejected at -/// `load_config_yaml` rather than discovered on first request. The -/// visitor → `OpaPdpFactory::build` → `OpaResolver::from_config` chain surfaces -/// `BuildError::ConfigShape` as a `PluginError`, which bubbles out of load. -#[tokio::test] -async fn malformed_on_error_is_rejected_at_load() { - const BAD_YAML: &str = r#" -global: - apl: - pdp: - - kind: opa - on_error: maybe - modules: - - "package authz\ndefault allow := false\n" -routes: - - tool: get_document - apl: - pre_invocation: - - opa: - query: data.authz.allow -"#; - let err = match build_manager_with_yaml(BAD_YAML).await { - Ok(_) => panic!("malformed on_error must fail load_config_yaml"), - Err(e) => e, - }; - let msg = format!("{err}"); - assert!( - msg.contains("on_error") && msg.contains("maybe"), - "load error should name the bad field and value; got: {msg}", - ); -} - -/// An `opa:` step with no `query` is an author bug the parser accepts opaquely; -/// the resolver only learns of it at request time. It must surface as a clean -/// Deny that halts the pipeline, never a panic. -#[tokio::test] -async fn missing_query_at_request_time_denies_without_panicking() { - const NO_QUERY_YAML: &str = r#" -global: - apl: - pdp: - - kind: opa - modules: - - "package authz\ndefault allow := false\n" -routes: - - tool: get_document - apl: - pre_invocation: - - opa: - on_deny: - - deny -"#; - let mgr = build_manager_with_yaml(NO_QUERY_YAML) - .await - .expect("an opa step without query is accepted at parse/load time"); - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &["reader"]))), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "a missing-query opa step must halt the pipeline, not allow through", - ); - assert!( - result.violation.is_some(), - "missing-query dispatch error must surface as a violation", - ); -} - -/// The deny-set idiom works end-to-end: a `deny[msg]` set-valued query allows -/// when empty and denies when non-empty. This is the dominant OPA authoring -/// style, so proving it through the real dispatcher matters. -#[tokio::test] -async fn deny_set_idiom_end_to_end() { - const DENY_SET_YAML: &str = r#" -global: - apl: - pdp: - - kind: opa - modules: - - | - package authz - deny contains msg if { - input.subject.id != "alice" - msg := "subject not on the allowlist" - } -routes: - - tool: get_document - apl: - pre_invocation: - - opa: - query: data.authz.deny -"#; - let mgr = build_manager_with_yaml(DENY_SET_YAML) - .await - .expect("load_config_yaml"); - - // alice → empty deny set → allow. - let (allow, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &[]))), - ..Default::default() - }, - None, - ) - .await; - assert!( - allow.continue_processing, - "empty deny set must allow; got violation = {:?}", - allow.violation - ); - - // eve → non-empty deny set → deny. - let (deny, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("eve", &[]))), - ..Default::default() - }, - None, - ) - .await; - assert!( - !deny.continue_processing && deny.violation.is_some(), - "non-empty deny set must halt with a violation", - ); -} - -/// External `data` declared in the config is loaded into the engine's `data` -/// root and readable by policy — an operator can port a policy that splits -/// logic (modules) from lookup tables (data) with no rewrite. -#[tokio::test] -async fn external_data_is_readable_end_to_end() { - const DATA_YAML: &str = r#" -global: - apl: - pdp: - - kind: opa - modules: - - | - package authz - default allow := false - allow if "reader" in data.roles[input.subject.id] - data: - roles: - alice: [reader] -routes: - - tool: get_document - apl: - pre_invocation: - - opa: - query: data.authz.allow -"#; - let mgr = build_manager_with_yaml(DATA_YAML) - .await - .expect("load_config_yaml"); - - let (result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - Extensions { - meta: Some(Arc::new(meta_for_tool("get_document"))), - security: Some(Arc::new(security_with_roles("alice", &[]))), - ..Default::default() - }, - None, - ) - .await; - assert!( - result.continue_processing, - "alice has the reader role in the data document and should be allowed; \ - got violation = {:?}", - result.violation - ); -} diff --git a/builtins/plugins/audit-logger/Cargo.toml b/builtins/plugins/audit-logger/Cargo.toml deleted file mode 100644 index d93bac8d..00000000 --- a/builtins/plugins/audit-logger/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -# Location: ./builtins/plugins/audit-logger/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-plugin-audit-logger — CMF plugin that emits a structured audit -# record for every dispatched request. Subject, client, action, -# delegation outcome, and capability-filtered context fields land -# in a single JSON line per call. Always allows; never blocks. - -[package] -name = "cpex-plugin-audit-logger" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX CMF plugin — structured per-request audit logging." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -cpex-core = { workspace = true } - -async-trait = { workspace = true } -chrono = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } - -[lints] -workspace = true diff --git a/builtins/plugins/audit-logger/src/config.rs b/builtins/plugins/audit-logger/src/config.rs deleted file mode 100644 index 577ba516..00000000 --- a/builtins/plugins/audit-logger/src/config.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Location: ./builtins/plugins/audit-logger/src/config.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AuditLoggerConfig { - /// Where audit records go. Stderr is the default — convenient - /// for the demo (`docker compose logs -f`) and for k8s sidecar - /// log forwarding. Tracing routes through whatever subscriber - /// the host installed. - #[serde(default)] - pub destination: AuditDestination, - - /// Optional sink name — surfaces in every record so a single - /// audit collector can distinguish multiple deployments. Free- - /// form string. - #[serde(default)] - pub source: Option, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AuditDestination { - /// Write one JSON line per call to stderr. - #[default] - Stderr, - /// Emit via `tracing::info!` at target `apl.audit`. Routed by - /// the host's subscriber to wherever traces normally go. - Tracing, -} diff --git a/builtins/plugins/audit-logger/src/factory.rs b/builtins/plugins/audit-logger/src/factory.rs deleted file mode 100644 index 2ef785c5..00000000 --- a/builtins/plugins/audit-logger/src/factory.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Location: ./builtins/plugins/audit-logger/src/factory.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor - -use std::sync::Arc; - -use cpex_core::{ - cmf::CmfHook, - error::PluginError, - factory::{PluginFactory, PluginInstance}, - hooks::TypedHandlerAdapter, - plugin::PluginConfig, -}; - -use crate::logger::AuditLogger; - -/// `kind:` string operators write in CPEX YAML to declare an audit -/// logger instance. -pub const KIND: &str = "audit/logger"; - -pub struct AuditLoggerFactory; - -impl PluginFactory for AuditLoggerFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let logger = Arc::new(AuditLogger::new(config.clone())?); - - if config.hooks.is_empty() { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-audit-logger): `hooks:` must list at \ - least one CMF hook to audit (e.g. cmf.tool_pre_invoke)", - config.name - ), - })); - } - - let handlers: Vec<_> = config - .hooks - .iter() - .map(|h| -> (&'static str, _) { - let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); - let adapter: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&logger))); - (leaked, adapter) - }) - .collect(); - - Ok(PluginInstance { - plugin: logger, - handlers, - }) - } -} diff --git a/builtins/plugins/audit-logger/src/lib.rs b/builtins/plugins/audit-logger/src/lib.rs deleted file mode 100644 index a980bdc7..00000000 --- a/builtins/plugins/audit-logger/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -// Location: ./builtins/plugins/audit-logger/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-plugin-audit-logger — CMF plugin that emits one structured JSON -// audit record per dispatched request. The record captures: -// -// * timestamp + correlation id -// * subject (id, roles, teams) and client (client_id, name) -// * entity (type + name) and tool args summary -// * delegation outcomes (which audiences got tokens, which -// scopes were granted) -// -// Mode: always allow — the plugin is observation-only. Operators -// who want to halt on audit failure would compose this with a -// downstream policy step. -// -// Output: -// -// * `destination: stderr` (default) — one JSON line per call, -// handy for the demo's `docker compose logs -f` flow. -// * `destination: tracing` — emit as a structured `tracing::info!` -// so it lands in whatever the host's subscriber routes to. -// -// Capabilities the plugin declares (operator wires them in YAML -// under `capabilities:`): -// -// * `read_subject` — for sub / roles / teams / claims -// * `read_client` — for client_id / client_name -// * `read_meta` — for entity_type / entity_name -// * `read_delegated_tokens` — to surface what got minted - -pub mod config; -pub mod factory; -pub mod logger; - -pub use config::{AuditDestination, AuditLoggerConfig}; -pub use factory::{AuditLoggerFactory, KIND}; -pub use logger::AuditLogger; diff --git a/builtins/plugins/audit-logger/src/logger.rs b/builtins/plugins/audit-logger/src/logger.rs deleted file mode 100644 index aade654c..00000000 --- a/builtins/plugins/audit-logger/src/logger.rs +++ /dev/null @@ -1,254 +0,0 @@ -// Location: ./builtins/plugins/audit-logger/src/logger.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor - -use std::sync::Arc; - -use async_trait::async_trait; -use serde_json::{json, Map, Value}; - -use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::PluginError; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use crate::config::{AuditDestination, AuditLoggerConfig}; - -/// Observation-only CMF plugin. Builds a structured audit record -/// from the request's MessagePayload + Extensions, emits to the -/// configured destination, returns `Allow`. Never blocks. -#[derive(Debug)] -pub struct AuditLogger { - cfg: PluginConfig, - typed: AuditLoggerConfig, -} - -impl AuditLogger { - pub fn new(cfg: PluginConfig) -> Result> { - let typed: AuditLoggerConfig = match cfg.config.as_ref() { - Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-audit-logger) config parse failed: {e}", - cfg.name - ), - }) - })?, - None => AuditLoggerConfig::default(), - }; - Ok(Self { cfg, typed }) - } - - fn build_record(&self, payload: &MessagePayload, ext: &Extensions) -> Value { - let mut record = Map::new(); - record.insert( - "ts".into(), - json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), - ); - record.insert("plugin".into(), json!(self.cfg.name)); - if let Some(src) = &self.typed.source { - record.insert("source".into(), json!(src)); - } - - // Subject — capability-filtered. Empty Subject means the - // plugin lacks `read_subject` cap (won't happen if the - // operator configured it correctly). - if let Some(sec) = ext.security.as_ref() { - if let Some(s) = &sec.subject { - record.insert( - "subject".into(), - json!({ - "id": s.id, - "roles": s.roles.iter().collect::>(), - "teams": s.teams.iter().collect::>(), - }), - ); - } - if let Some(c) = &sec.client { - record.insert( - "client".into(), - json!({ - "client_id": c.client_id, - "client_name": c.client_name, - }), - ); - } - } - - // Entity — the route's tool/prompt/resource coords. - if let Some(meta) = ext.meta.as_ref() { - record.insert( - "entity".into(), - json!({ - "type": meta.entity_type, - "name": meta.entity_name, - }), - ); - } - - // Tool / prompt args summary — the first structured - // content part's args, if any. Mirrors what the gateway - // would actually forward (so audit reflects post-redact - // state if a PII scanner ran ahead of us). - for part in &payload.message.content { - match part { - ContentPart::ToolCall { content } => { - record.insert( - "tool_call".into(), - json!({ - "name": content.name, - "tool_call_id": content.tool_call_id, - "args": content.arguments, - }), - ); - break; - }, - ContentPart::PromptRequest { content } => { - record.insert( - "prompt_request".into(), - json!({ - "name": content.name, - "args": content.arguments, - }), - ); - break; - }, - _ => {}, - } - } - - // Delegation outcomes — which audiences got tokens, with - // what (effective, possibly narrowed) scopes. The whole - // point of including this: it makes the audit trail show - // "we exchanged for workday-api with scope=read_compensation", - // which is the proof that delegation enforcement happened. - if let Some(raw) = ext.raw_credentials.as_ref() { - if !raw.delegated_tokens.is_empty() { - let tokens: Vec = raw - .delegated_tokens - .iter() - .map(|(_key, tok)| { - json!({ - "audience": tok.audience, - "scopes": tok.scopes, - "outbound_header": tok.outbound_header, - "expires_at": tok.expires_at.to_rfc3339_opts( - chrono::SecondsFormat::Secs, true, - ), - }) - }) - .collect(); - record.insert("delegated_tokens".into(), json!(tokens)); - } - } - - Value::Object(record) - } - - fn emit(&self, record: &Value) { - match self.typed.destination { - AuditDestination::Stderr => { - // One JSON line — easy to grep / forward / jq through. - eprintln!("{}", record); - }, - AuditDestination::Tracing => { - tracing::info!(target: "apl.audit", record = %record, "audit"); - }, - } - } -} - -#[async_trait] -impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AuditLogger { - async fn handle( - &self, - payload: &MessagePayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let record = self.build_record(payload, ext); - self.emit(&record); - PluginResult::allow() - } -} - -// Silence import-unused warning if Arc isn't used elsewhere. -#[allow(dead_code)] -fn _force_link_arc(_: Arc<()>) {} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::cmf::{Message, Role, ToolCall}; - use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension}; - use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; - use std::collections::HashMap; - use std::sync::Arc; - - fn cfg() -> PluginConfig { - PluginConfig { - name: "audit".into(), - kind: "test".into(), - hooks: vec!["cmf.tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 50, - on_error: OnError::Fail, - config: Some(serde_json::json!({ "destination": "stderr" })), - ..Default::default() - } - } - - #[tokio::test] - async fn build_record_includes_subject_entity_toolcall() { - let plugin = AuditLogger::new(cfg()).unwrap(); - let payload = MessagePayload { - message: Message::with_content( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "1".into(), - name: "get_compensation".into(), - arguments: HashMap::from([( - "employee_id".to_string(), - serde_json::json!("EMP-001234"), - )]), - namespace: None, - }, - }], - ), - }; - let mut sec = SecurityExtension::default(); - sec.subject = Some(SubjectExtension { - id: Some("alice@corp.com".into()), - ..Default::default() - }); - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".into()); - meta.entity_name = Some("get_compensation".into()); - let ext = Extensions { - security: Some(Arc::new(sec)), - meta: Some(Arc::new(meta)), - ..Default::default() - }; - - let record = plugin.build_record(&payload, &ext); - assert_eq!(record["subject"]["id"], "alice@corp.com"); - assert_eq!(record["entity"]["name"], "get_compensation"); - assert_eq!(record["tool_call"]["name"], "get_compensation"); - assert_eq!(record["tool_call"]["args"]["employee_id"], "EMP-001234"); - // Always-allow contract: handler returns continue_processing. - let mut ctx = PluginContext::default(); - let r = plugin.handle(&payload, &ext, &mut ctx).await; - assert!(r.continue_processing); - assert!(r.violation.is_none()); - } -} diff --git a/builtins/plugins/delegator-biscuit/Cargo.toml b/builtins/plugins/delegator-biscuit/Cargo.toml index 0cd2d5fe..e473e65c 100644 --- a/builtins/plugins/delegator-biscuit/Cargo.toml +++ b/builtins/plugins/delegator-biscuit/Cargo.toml @@ -24,15 +24,15 @@ # inbound biscuit; completion blocks land in a future post-result # audit hook. # -# # When to reach for this vs `cpex-plugin-delegator-oauth` +# # When to reach for this vs PPE's `delegator/oauth` builtin # # - **`cpex-plugin-delegator-biscuit`** — capability tokens, cryptographic # attenuation, no IdP roundtrip. Use for federated agent # ecosystems where there's no shared IdP, or for performance- # sensitive paths where the IdP roundtrip cost matters. -# - **`cpex-plugin-delegator-oauth`** (slice 6) — RFC 8693 against an -# OAuth IdP. Use when centralized audit/revocation matters more -# than roundtrip cost. +# - **`delegator/oauth`** (PPE builtin kind, registered by +# `cpex::install_builtins`) — RFC 8693 against an OAuth IdP. Use +# when centralized audit/revocation matters more than roundtrip cost. [package] name = "cpex-plugin-delegator-biscuit" @@ -48,7 +48,7 @@ categories.workspace = true rust-version.workspace = true [dependencies] -cpex-core = { workspace = true } +praxis-policy-core = { workspace = true } # biscuit-auth v6 — current major. Maintained by Clever Cloud + # community. Ed25519 + Datalog. No default-features off needed; the diff --git a/builtins/plugins/delegator-biscuit/src/delegator.rs b/builtins/plugins/delegator-biscuit/src/delegator.rs index 0f7233bc..33c733a7 100644 --- a/builtins/plugins/delegator-biscuit/src/delegator.rs +++ b/builtins/plugins/delegator-biscuit/src/delegator.rs @@ -40,13 +40,13 @@ use biscuit_auth::builder::BlockBuilder; use biscuit_auth::{Biscuit, PublicKey}; use chrono::Utc; -use cpex_core::context::PluginContext; -use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::delegation::{DelegationPayload, TokenDelegateHook}; +use praxis_policy_core::error::{PluginError, PluginViolation}; +use praxis_policy_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::hooks::trait_def::{HookHandler, PluginResult}; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; use super::config::BiscuitDelegatorConfig; diff --git a/builtins/plugins/delegator-biscuit/src/lib.rs b/builtins/plugins/delegator-biscuit/src/lib.rs index c1750643..b9faf178 100644 --- a/builtins/plugins/delegator-biscuit/src/lib.rs +++ b/builtins/plugins/delegator-biscuit/src/lib.rs @@ -6,9 +6,10 @@ // cpex-plugin-delegator-biscuit — `TokenDelegateHandler` backed by biscuit // capability-token attenuation. // -// The host registers this against `token.delegate`; outbound -// forwarding plugins invoke `mgr.invoke_named::(...)` -// with a `DelegationPayload` whose `bearer_token` is a base64- +// The host registers this against `token.delegate` on a PPE +// `PolicyEngine`; outbound forwarding plugins invoke +// `engine.invoke_named::(...)` with a +// `DelegationPayload` whose `bearer_token` is a base64- // encoded biscuit. This handler parses + verifies the inbound // biscuit against the configured root public key, appends a // delegation block that narrows the capabilities per the route's @@ -22,9 +23,6 @@ // block (the inbound) + one delegation block (our attenuation). // Subsequent hops can each append further blocks. Completion blocks // (post-execution audit) are a future hook family. -// -// Scope: module structure only. Real implementation and integration -// tests land later. pub mod config; pub mod delegator; diff --git a/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs b/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs index 70033dfe..9202ff06 100644 --- a/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs +++ b/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs @@ -3,28 +3,34 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// End-to-end tests for `BiscuitDelegator`. Generates a root keypair -// in-process, mints an authority-only biscuit (the "inbound"), runs -// the delegator's `handle()`, and verifies that the resulting -// attenuated biscuit is well-formed: the root key still verifies -// the chain, and the new delegation block carries the expected -// `delegated_to` / `audience` / `operation` checks. +// End-to-end tests for `BiscuitDelegator` on a `PolicyEngine`. Each +// test loads a PPE config document through a test-local factory, +// generates a root keypair in-process, mints an authority-only +// biscuit (the "inbound"), dispatches `token.delegate`, and verifies +// that the resulting attenuated biscuit is well-formed: the root key +// still verifies the chain, and the new delegation block carries the +// expected `delegated_to` / `audience` / `operation` / time checks. use std::sync::Arc; use biscuit_auth::{ - builder::{AuthorizerBuilder, BlockBuilder}, Biscuit, KeyPair, + builder::{AuthorizerBuilder, BlockBuilder}, }; +use chrono::Utc; -use cpex_core::delegation::{ - AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, - HOOK_TOKEN_DELEGATE, +use praxis_policy_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, HOOK_TOKEN_DELEGATE, TargetType, + TokenDelegateHook, }; -use cpex_core::extensions::raw_credentials::DelegationMode; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::PluginError; +use praxis_policy_core::executor::PipelineResult; +use praxis_policy_core::extensions::raw_credentials::DelegationMode; +use praxis_policy_core::factory::{PluginFactory, PluginInstance}; +use praxis_policy_core::hooks::TypedHandlerAdapter; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::plugin::PluginConfig; use cpex_plugin_delegator_biscuit::BiscuitDelegator; @@ -34,6 +40,8 @@ use serde_json::json; // Fixtures // ===================================================================== +const KIND: &str = "delegator/biscuit"; + struct Roots { keypair: KeyPair, } @@ -46,6 +54,24 @@ fn roots() -> &'static Roots { }) } +/// Test-local factory: builds a `BiscuitDelegator` from the plugin's +/// config entry and registers it on `token.delegate`, the same way a +/// host wires any delegator kind into the engine. +struct BiscuitDelegatorFactory; + +impl PluginFactory for BiscuitDelegatorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let delegator = Arc::new(BiscuitDelegator::new(config.clone())?); + let handler = Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&delegator), + )); + Ok(PluginInstance { + plugin: delegator, + handlers: vec![(HOOK_TOKEN_DELEGATE, handler)], + }) + } +} + /// Mint a fresh authority-only biscuit carrying the given Datalog /// (capabilities the principal holds). Returns base64-encoded /// biscuit ready to hand to the delegator as `bearer_token`. @@ -61,36 +87,49 @@ fn mint_inbound_biscuit(authority_datalog: &str) -> String { .expect("biscuit serializes") } -fn plugin_config() -> PluginConfig { +/// PPE config document for one biscuit delegator. Delegation is +/// host-dispatched (`invoke_named`), not reached from a policy step, +/// so the document uses hook dispatch and the plugin declares the +/// `token.delegate` hook itself. The delegator reads nothing from +/// the request extensions, so it declares no capabilities. +fn config_yaml(root_key_hex: &str, outbound_header: &str, ttl_seconds: u64) -> String { + format!( + r#" +engine_settings: + dispatch: hooks +plugins: + - name: biscuit-delegator + kind: {KIND} + hooks: [{HOOK_TOKEN_DELEGATE}] + mode: sequential + on_error: fail + config: + root_public_key: + kind: hex + hex: "{root_key_hex}" + default_outbound_header: {outbound_header} + default_ttl_seconds: {ttl_seconds} +"# + ) +} + +fn load_engine(yaml: &str) -> Result, Box> { + let engine = Arc::new(PolicyEngine::default()); + engine.register_factory(KIND, Box::new(BiscuitDelegatorFactory)); + engine.load_config_yaml(yaml)?; + Ok(engine) +} + +async fn build_engine_with(outbound_header: &str, ttl_seconds: u64) -> Arc { let pub_hex = hex::encode(roots().keypair.public().to_bytes()); - PluginConfig { - name: "biscuit-delegator".into(), - kind: "test".into(), - hooks: vec![HOOK_TOKEN_DELEGATE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(json!({ - "root_public_key": { "kind": "hex", "hex": pub_hex }, - "default_outbound_header": "Authorization", - "default_ttl_seconds": 300, - })), - ..Default::default() - } + let engine = + load_engine(&config_yaml(&pub_hex, outbound_header, ttl_seconds)).expect("config loads"); + engine.initialize().await.expect("engine initializes"); + engine } -async fn build_manager() -> Arc { - let cfg = plugin_config(); - let delegator = BiscuitDelegator::new(cfg.clone()).expect("delegator constructs"); - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::new(delegator), - cfg, - &[HOOK_TOKEN_DELEGATE], - ) - .unwrap(); - mgr.initialize().await.unwrap(); - mgr +async fn build_engine() -> Arc { + build_engine_with("Authorization", 300).await } fn build_payload( @@ -112,11 +151,8 @@ fn build_payload( }) } -async fn invoke( - mgr: &Arc, - payload: DelegationPayload, -) -> cpex_core::executor::PipelineResult { - let (result, _bg) = mgr +async fn invoke(engine: &Arc, payload: DelegationPayload) -> PipelineResult { + let (result, _bg) = engine .invoke_named::( HOOK_TOKEN_DELEGATE, payload, @@ -127,6 +163,21 @@ async fn invoke( result } +/// Pull the `check if time($t), $t <= N` bound out of a block's +/// printed Datalog. +fn expiry_bound(block_source: &str) -> i64 { + let marker = "$t <= "; + let start = block_source + .find(marker) + .expect("delegation block carries a time-bound check") + + marker.len(); + let digits: String = block_source[start..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().expect("time bound is an integer") +} + // ===================================================================== // Scenarios // ===================================================================== @@ -143,7 +194,7 @@ async fn happy_path_attenuates_biscuit() { "#, ); - let mgr = build_manager().await; + let engine = build_engine().await; let payload = build_payload( inbound.clone(), "get_compensation", @@ -151,7 +202,7 @@ async fn happy_path_attenuates_biscuit() { &["read"], ); - let result = invoke(&mgr, payload).await; + let result = invoke(&engine, payload).await; assert!( result.continue_processing, "happy path should mint a token: violation = {:?}", @@ -217,7 +268,7 @@ async fn happy_path_attenuates_biscuit() { #[tokio::test] async fn attenuated_token_denies_wrong_operation() { let inbound = mint_inbound_biscuit(r#"right("read");"#); - let mgr = build_manager().await; + let engine = build_engine().await; let payload = build_payload( inbound, "get_compensation", @@ -225,7 +276,7 @@ async fn attenuated_token_denies_wrong_operation() { &["read"], ); - let result = invoke(&mgr, payload).await; + let result = invoke(&engine, payload).await; assert!(result.continue_processing); let final_payload = DelegationPayload::from_pipeline_result(&result).unwrap(); let minted = final_payload.delegated_token.as_ref().unwrap(); @@ -258,7 +309,7 @@ async fn wrong_root_key_rejects() { .to_base64() .unwrap(); - let mgr = build_manager().await; + let engine = build_engine().await; let payload = build_payload( foreign_biscuit, "tool", @@ -266,7 +317,7 @@ async fn wrong_root_key_rejects() { &["read"], ); - let result = invoke(&mgr, payload).await; + let result = invoke(&engine, payload).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "delegation.token_invalid"); @@ -276,11 +327,11 @@ async fn wrong_root_key_rejects() { /// parsing attempted. #[tokio::test] async fn empty_bearer_token_rejects() { - let mgr = build_manager().await; + let engine = build_engine().await; let payload = DelegationPayload::new("", "tool").with_target_audience("https://downstream.example.com"); - let result = invoke(&mgr, payload).await; + let result = invoke(&engine, payload).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "delegation.bad_request"); @@ -292,10 +343,10 @@ async fn empty_bearer_token_rejects() { #[tokio::test] async fn missing_audience_rejects() { let inbound = mint_inbound_biscuit(r#"right("read");"#); - let mgr = build_manager().await; + let engine = build_engine().await; let payload = DelegationPayload::new(inbound, "tool"); // no audience - let result = invoke(&mgr, payload).await; + let result = invoke(&engine, payload).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "delegation.bad_request"); @@ -306,7 +357,7 @@ async fn missing_audience_rejects() { /// `delegation.token_invalid`. #[tokio::test] async fn malformed_bearer_token_rejects() { - let mgr = build_manager().await; + let engine = build_engine().await; let payload = build_payload( "this-is-not-a-biscuit".to_string(), "tool", @@ -314,8 +365,103 @@ async fn malformed_bearer_token_rejects() { &["read"], ); - let result = invoke(&mgr, payload).await; + let result = invoke(&engine, payload).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "delegation.token_invalid"); } + +/// The appended delegation block's caveats follow the config and the +/// request: the block names the target and audience, checks each +/// required permission, and bounds time at the shorter of the +/// configured default TTL and the route hint. The minted token uses +/// the configured outbound header. +#[tokio::test] +async fn delegation_block_caveats_match_config() { + let inbound = mint_inbound_biscuit(r#"right("read"); right("write");"#); + // Default TTL (60s) is shorter than the route hint (120s), so + // the configured default wins. + let engine = build_engine_with("X-AIP-Token", 60).await; + let payload = build_payload( + inbound, + "get_compensation", + "https://hr.example.com", + &["read", "write"], + ); + + let before = Utc::now().timestamp(); + let result = invoke(&engine, payload).await; + let after = Utc::now().timestamp(); + assert!( + result.continue_processing, + "delegation should succeed: violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result).unwrap(); + let minted = final_payload.delegated_token.as_ref().unwrap(); + assert_eq!(minted.outbound_header, "X-AIP-Token"); + assert_eq!(minted.scopes, vec!["read", "write", "audit"]); + + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()).unwrap(); + let last = attenuated.block_count() - 1; + let block = attenuated + .print_block_source(last) + .expect("delegation block prints"); + + assert!( + block.contains(r#"delegated_to("get_compensation");"#), + "{block}" + ); + assert!( + block.contains(r#"audience("https://hr.example.com");"#), + "{block}" + ); + assert!(block.contains(r#"check if operation("read");"#), "{block}"); + assert!(block.contains(r#"check if operation("write");"#), "{block}"); + // Route capabilities are recorded as token scopes, not caveats. + assert!(!block.contains(r#"operation("audit")"#), "{block}"); + + let bound = expiry_bound(&block); + assert!( + (before + 60..=after + 60).contains(&bound), + "time bound {bound} should be now + 60s (window {before}..={after})", + ); + + // A verifier past the bound is denied by the time caveat. + let mut late = AuthorizerBuilder::new() + .code(format!( + r#"operation("read"); operation("write"); time({}); allow if true;"#, + bound + 1 + )) + .unwrap() + .build(&attenuated) + .unwrap(); + assert!( + late.authorize().is_err(), + "token must be unusable past its time bound", + ); +} + +/// A root key that is not a valid Ed25519 public key fails at plugin +/// construction, so the config never loads, and the error names the +/// plugin and the offending field. +#[tokio::test] +async fn invalid_root_key_fails_plugin_initialization() { + for (bad_hex, expect) in [ + ("deadbeef", "32 bytes"), + ("not-hex-at-all", "isn't valid hex"), + ] { + let Err(err) = load_engine(&config_yaml(bad_hex, "Authorization", 300)) else { + panic!("an invalid root key must fail to load ({bad_hex})"); + }; + let msg = err.to_string(); + assert!( + matches!(*err, PluginError::Config { .. }), + "expected a config error, got {err:?}", + ); + assert!(msg.contains("biscuit-delegator"), "names the plugin: {msg}"); + assert!(msg.contains("root_public_key"), "names the field: {msg}"); + assert!(msg.contains(expect), "explains the fault: {msg}"); + } +} diff --git a/builtins/plugins/delegator-oauth/Cargo.toml b/builtins/plugins/delegator-oauth/Cargo.toml deleted file mode 100644 index d21ea22f..00000000 --- a/builtins/plugins/delegator-oauth/Cargo.toml +++ /dev/null @@ -1,80 +0,0 @@ -# Location: ./builtins/plugins/delegator-oauth/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-plugin-delegator-oauth — `TokenDelegateHandler` that performs RFC 8693 -# OAuth 2.0 token exchange against any compliant IdP. -# -# # Why this exists -# -# Slice 3's `TokenDelegateHook` defines the surface (`DelegationPayload` -# in, `RawDelegatedToken` out via `apply_to_extensions`); this crate -# is the *backend* — the part that actually mints the downstream -# credential. The IdP-mediated path: POST to the IdP's `/token` -# endpoint with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, -# parse the JSON response, build a `RawDelegatedToken`. -# -# # When to reach for this vs `cpex-plugin-delegator-biscuit` -# -# - **`cpex-plugin-delegator-oauth`** (this crate) — IdP-mediated. Use when -# the deployment already runs an OAuth server (Keycloak, Auth0, -# Hydra, Zitadel, Janssen Jans Auth Server) and wants centralized -# audit/revocation. Every delegation costs an IdP roundtrip; -# gateway must hold IdP client credentials. -# - **`cpex-plugin-delegator-biscuit`** (slice 7) — decentralized capability -# tokens via biscuit attenuation. No IdP roundtrip. Use for -# federated agent ecosystems where there's no shared IdP. -# -# Both implement `HookHandler` and are -# swappable at config time. - -[package] -name = "cpex-plugin-delegator-oauth" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX delegation handler — RFC 8693 OAuth 2.0 token exchange." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -cpex-core = { workspace = true } - -# `reqwest` for the HTTP POST to the IdP token endpoint. Default -# features pull `rustls` for TLS — we explicitly disable the -# default `default-tls` (native-tls) and pick `rustls-tls` instead -# for a cleaner build on macOS/Linux (no openssl link). -# `json` feature for `.json()` response parsing; we send -# application/x-www-form-urlencoded ourselves (RFC 8693 wants form). -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } - -async-trait = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } - -# `base64` decodes the minted token's JWT payload for a best-effort, -# read-only interop check (did the IdP honor the RFC 8693 `actor_token` -# and emit an `act` claim). We never verify the signature here — the -# token is already trusted, having just come from our own IdP roundtrip. -base64 = "0.22" - -# Secret-clearing wrapper for client credentials in memory. -zeroize = { version = "1.8", features = ["zeroize_derive"] } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } -# `mockito` stands up an HTTP server in the test process so we can -# verify the request body shape + simulate IdP responses without -# touching the network. -mockito = "1" - -[lints] -workspace = true diff --git a/builtins/plugins/delegator-oauth/src/config.rs b/builtins/plugins/delegator-oauth/src/config.rs deleted file mode 100644 index 8807fdfb..00000000 --- a/builtins/plugins/delegator-oauth/src/config.rs +++ /dev/null @@ -1,197 +0,0 @@ -// Location: ./builtins/plugins/delegator-oauth/src/config.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Typed configuration for `OAuthDelegator`. Deserializes from the -// plugin's `PluginConfig.config: Option` field; the -// delegator's constructor reads this and builds the runtime state -// (the `reqwest::Client`, the loaded client secret). -// -// Serializable intermediate representations stand in for non- -// serializable runtime types (e.g., the secret is loaded from -// env-var / file / literal at construction time, never serialized -// back out). - -use std::path::PathBuf; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -/// Top-level plugin config — what operators write under -/// `plugins[].config:` in unified-config YAML. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthDelegatorConfig { - /// IdP's token endpoint URL — where the token-exchange POST - /// lands (e.g., `https://auth.example.com/oauth/token`). - pub token_endpoint: String, - - /// OAuth `client_id` identifying our gateway to the IdP. The - /// IdP authenticates us with `(client_id, client_secret)` over - /// HTTP Basic / form-body before honoring the exchange request. - pub client_id: String, - - /// Where to load the client secret from. See [`ClientSecretSource`]. - pub client_secret_source: ClientSecretSource, - - /// What `subject_token_type` we tell the IdP the inbound token - /// is. RFC 8693 defines `access_token`, `refresh_token`, - /// `id_token`, `jwt`, `saml1`, `saml2`. Most deployments use - /// access_token — that's the default. - #[serde(default = "default_subject_token_type")] - pub subject_token_type: String, - - /// Request timeout. The exchange is on the request hot path — - /// a 5s default keeps requests bounded if the IdP is slow. - #[serde(default = "default_timeout_seconds")] - pub timeout_seconds: u64, - - /// Header name the forwarding plugin should attach the minted - /// token under when calling the downstream service. - /// Most targets expect `Authorization`; some bespoke services - /// want a different header (`X-Service-Token`, etc.). - #[serde(default = "default_outbound_header")] - pub default_outbound_header: String, - - /// Explicitly allow `http://` for `token_endpoint`. By default, - /// the constructor rejects non-https URLs because the - /// token-exchange POST sends `client_id:client_secret` and the - /// inbound user JWT — leaking either over plaintext defeats the - /// whole exchange. Set to `true` ONLY for `http://localhost` - /// development against a docker-compose IdP. Production - /// deployments must leave this at the default (`false`). - #[serde(default)] - pub insecure_http: bool, - - /// The `actor_token_type` we tell the IdP the RFC 8693 - /// `actor_token` is — a token-type URN. Defaults to - /// `...:token-type:jwt` because the actor is almost always a - /// JWT-SVID. Only consulted when the `DelegationPayload` carries a - /// non-empty `actor_token` (attached upstream by the invoker from - /// the inbound workload SVID); otherwise the exchange stays - /// single-token and behaves exactly as before. - #[serde(default = "default_actor_token_type")] - pub actor_token_type: String, - - /// The `client_assertion_type` used in leg 1 of a workload - /// delegation (`subject: caller_workload`), where the calling - /// agent authenticates by presenting its JWT-SVID as an RFC 7523 - /// client assertion rather than a secret. Defaults to the - /// SPIFFE-specific URN from draft-ietf-oauth-spiffe-client-auth — - /// NOT the generic `...:jwt-bearer` — because that's what a SPIFFE - /// authorization server (e.g. Keycloak's SPIFFE provider) expects. - /// Only consulted on the `caller_workload` path; every other - /// subject authenticates with the client secret as before. - #[serde(default = "default_workload_assertion_type")] - pub workload_assertion_type: String, -} - -/// Where the gateway's OAuth client secret is loaded from. Three -/// modes covering the common deployment patterns: -/// -/// * **`env_var`** — read from a named environment variable at -/// resolver construction. Production-friendly; secret lives in -/// the host's environment, not in committed config. -/// * **`file`** — read from a file path at construction. Useful -/// for Kubernetes secret volumes (`/var/run/secrets/...`) or -/// similar mounted-secret patterns. -/// * **`literal`** — inline secret string. Convenient for tests -/// and dev configs; **never** for production (secret ends up -/// in committed YAML). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum ClientSecretSource { - EnvVar { name: String }, - File { path: PathBuf }, - Literal { secret: String }, -} - -fn default_subject_token_type() -> String { - "urn:ietf:params:oauth:token-type:access_token".to_string() -} - -fn default_actor_token_type() -> String { - "urn:ietf:params:oauth:token-type:jwt".to_string() -} - -fn default_workload_assertion_type() -> String { - "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe".to_string() -} - -fn default_timeout_seconds() -> u64 { - 5 -} - -fn default_outbound_header() -> String { - "Authorization".to_string() -} - -impl OAuthDelegatorConfig { - /// Helper used by the constructor — exposed for tests. - pub fn timeout(&self) -> Duration { - Duration::from_secs(self.timeout_seconds) - } -} - -impl ClientSecretSource { - /// Resolve the secret at runtime, returning the raw bytes. - /// Errors as a string so the caller wraps in `PluginError::Config` - /// with context. - pub fn resolve(&self) -> Result { - match self { - Self::EnvVar { name } => { - std::env::var(name).map_err(|e| format!("env var '{name}' unavailable: {e}")) - }, - Self::File { path } => std::fs::read_to_string(path) - .map(|s| s.trim().to_string()) - .map_err(|e| format!("secret file '{}' unreadable: {e}", path.display())), - Self::Literal { secret } => Ok(secret.clone()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn config_deserializes_from_json() { - let raw = json!({ - "token_endpoint": "https://auth.example.com/oauth/token", - "client_id": "gateway", - "client_secret_source": { "kind": "literal", "secret": "dev-only" }, - }); - let cfg: OAuthDelegatorConfig = serde_json::from_value(raw).unwrap(); - assert_eq!(cfg.token_endpoint, "https://auth.example.com/oauth/token"); - assert_eq!(cfg.client_id, "gateway"); - assert_eq!(cfg.timeout_seconds, 5); - assert_eq!(cfg.default_outbound_header, "Authorization"); - // actor_token_type defaults to the JWT token-type URN (the - // actor is almost always a JWT-SVID); only used when the - // payload carries a non-empty actor_token. - assert_eq!(cfg.actor_token_type, "urn:ietf:params:oauth:token-type:jwt"); - // workload_assertion_type defaults to the SPIFFE-specific - // client-assertion URN (leg 1 of a caller_workload delegation). - assert_eq!( - cfg.workload_assertion_type, - "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" - ); - } - - #[test] - fn literal_secret_resolves() { - let src = ClientSecretSource::Literal { - secret: "hush".into(), - }; - assert_eq!(src.resolve().unwrap(), "hush"); - } - - #[test] - fn missing_env_var_errors() { - let src = ClientSecretSource::EnvVar { - name: "_THIS_VAR_DEFINITELY_NOT_SET_FOR_TESTS_".into(), - }; - assert!(src.resolve().is_err()); - } -} diff --git a/builtins/plugins/delegator-oauth/src/delegator.rs b/builtins/plugins/delegator-oauth/src/delegator.rs deleted file mode 100644 index 1de0374b..00000000 --- a/builtins/plugins/delegator-oauth/src/delegator.rs +++ /dev/null @@ -1,734 +0,0 @@ -// Location: ./builtins/plugins/delegator-oauth/src/delegator.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `OAuthDelegator` — `HookHandler` that performs -// RFC 8693 OAuth 2.0 Token Exchange against the configured IdP. -// -// # Flow -// -// 1. Read `payload.bearer_token()` (caller's current credential) -// and `payload.target_audience()` / `required_permissions()` / -// `route_attenuation` (the narrowing config). -// 2. Build the form-encoded body per RFC 8693: -// grant_type=urn:ietf:params:oauth:grant-type:token-exchange -// subject_token= -// subject_token_type= -// audience= -// scope= -// actor_token= (only if payload carries one) -// actor_token_type= (only if actor_token sent) -// 3. POST to the IdP's token endpoint with HTTP Basic auth -// (client_id / client_secret). -// 4. Parse the JSON response: `{ access_token, token_type, -// expires_in, scope, issued_token_type }`. -// 5. Construct a `RawDelegatedToken` with the minted credential + -// computed expiry + effective scopes. -// 6. Return updated payload via `PluginResult::modify_payload`. -// -// # Error handling -// -// Construction errors → `Box` (`PluginError::Config`). -// Runtime errors → `PluginResult::deny(PluginViolation::new(code, -// reason))`: -// * `delegation.idp_unreachable` — network failure -// * `delegation.idp_timeout` — exceeded `timeout_seconds` -// * `delegation.idp_rejected` — IdP returned 4xx/5xx -// * `delegation.bad_response` — response not valid JSON or -// missing required fields -// * `delegation.scope_too_broad` — IdP returned a token whose -// scopes don't include all -// requested permissions - -use std::borrow::Cow; -use std::sync::Arc; - -use async_trait::async_trait; -use chrono::Utc; -use serde::Deserialize; -use zeroize::Zeroizing; - -use cpex_core::context::PluginContext; -use cpex_core::delegation::{DelegationPayload, DelegationSubject, TokenDelegateHook}; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::extensions::raw_credentials::RawDelegatedToken; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use super::config::OAuthDelegatorConfig; - -/// RFC 8693 token-exchange grant type — the value of -/// `grant_type` in the form-encoded request body. -const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; - -/// RFC 6749 §4.4 client-credentials grant — "give me a token as -/// myself". Used when the delegation subject is `this_workload` (this -/// CPEX instance itself): there is no inbound credential to exchange, -/// and its identity is the OAuth client identity it already -/// authenticates with. -const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials"; - -/// Default issued-token-type RFC 8693 returns. We don't rely on it -/// for behavior — it's reported back to operators in audit logs -/// only. -const DEFAULT_ISSUED_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; - -/// OAuth-mediated `TokenDelegate` handler. -pub struct OAuthDelegator { - cfg: PluginConfig, - typed: OAuthDelegatorConfig, - /// Loaded client secret, zeroized on drop. - client_secret: Zeroizing, - /// Shared HTTP client. Pre-built so repeated invocations - /// reuse connections / TLS sessions. - http: reqwest::Client, - /// Latches once the best-effort "actor requested but no `act` minted" - /// warning has fired, so it logs at most once per delegator instead of - /// on every request (and skips the per-request JWT decode thereafter). - warned_missing_act: std::sync::atomic::AtomicBool, -} - -impl std::fmt::Debug for OAuthDelegator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OAuthDelegator") - .field("cfg", &self.cfg.name) - .field("token_endpoint", &self.typed.token_endpoint) - .field("client_id", &self.typed.client_id) - .field("client_secret", &"") - .finish() - } -} - -impl OAuthDelegator { - /// Build a delegator from a `PluginConfig`. Reads `cfg.config` - /// into [`OAuthDelegatorConfig`], resolves the client secret, - /// constructs the shared `reqwest::Client`. - pub fn new(cfg: PluginConfig) -> Result> { - let raw = cfg.config.as_ref().ok_or_else(|| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth) requires a `config:` block", - cfg.name - ), - }) - })?; - let typed: OAuthDelegatorConfig = serde_json::from_value(raw.clone()).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth) config parse failed: {e}", - cfg.name - ), - }) - })?; - - if typed.token_endpoint.trim().is_empty() { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth): token_endpoint must be non-empty", - cfg.name - ), - })); - } - // Reject http:// for token_endpoint by default. The exchange - // POST sends client_id:client_secret + inbound user JWT; - // sending these over plaintext defeats the whole flow. - // `insecure_http: true` is the conscious opt-out for - // localhost docker-compose demos. - if let Err(e) = require_https(&typed.token_endpoint, typed.insecure_http) { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth): token_endpoint {e}", - cfg.name, - ), - })); - } - if typed.client_id.trim().is_empty() { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth): client_id must be non-empty", - cfg.name - ), - })); - } - - let secret = typed.client_secret_source.resolve().map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth) client secret resolve failed: {e}", - cfg.name - ), - }) - })?; - - let http = reqwest::Client::builder() - .timeout(typed.timeout()) - .build() - .map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-oauth) HTTP client build failed: {e}", - cfg.name - ), - }) - })?; - - Ok(Self { - cfg, - typed, - client_secret: Zeroizing::new(secret), - http, - warned_missing_act: std::sync::atomic::AtomicBool::new(false), - }) - } - - /// Compose the requested scope set: the target's required - /// permissions plus any extra capabilities from - /// `route_attenuation`. Returns a space-separated string per - /// OAuth conventions. - fn requested_scopes(payload: &DelegationPayload) -> String { - let mut scopes: Vec = payload.required_permissions().to_vec(); - if let Some(att) = payload.route_attenuation() { - for cap in &att.capabilities { - if !scopes.contains(cap) { - scopes.push(cap.clone()); - } - } - } - scopes.join(" ") - } - - /// Leg 1 of a workload delegation (`subject: caller_workload`): - /// authenticate the calling agent by presenting its JWT-SVID as an - /// RFC 7523 client assertion, and return the IdP-issued base token. - /// - /// There is no Basic auth and no `client_id` — the assertion *is* - /// the client credential, and the IdP resolves which client from - /// the SVID's `sub` (draft-ietf-oauth-spiffe-client-auth). The base - /// token this returns then becomes the `subject_token` of the - /// ordinary exchange (leg 2), which is where the downstream - /// audience/scope — the authority the agent itself lacks — is - /// actually granted. Splitting it this way is what keeps the - /// enforcement point, not the agent, as the holder of downstream authority. - /// - /// Errors map to the same `delegation.*` violation codes the - /// exchange uses, so a failed leg 1 denies the whole delegation. - async fn mint_base_token(&self, svid: &str) -> Result { - let form = [ - ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), - ( - "client_assertion_type", - self.typed.workload_assertion_type.as_str(), - ), - ("client_assertion", svid), - ]; - - let response = match self - .http - .post(&self.typed.token_endpoint) - .form(&form) - .send() - .await - { - Ok(r) => r, - Err(e) if e.is_timeout() => { - return Err(PluginViolation::new( - "delegation.idp_timeout", - format!( - "workload client_assertion to {} timed out", - self.typed.token_endpoint - ), - )); - }, - Err(e) => { - return Err(PluginViolation::new( - "delegation.idp_unreachable", - format!( - "workload client_assertion POST to {} failed: {e}", - self.typed.token_endpoint - ), - )); - }, - }; - - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - // Sanitize: surface only the OAuth `error` CODE (a fixed - // vocabulary — invalid_client, invalid_grant, …), never the - // free-text `error_description` or the raw body. Leg 1 submits - // the SVID as a `client_assertion`, and an IdP may echo that - // credential material back in those fields. - let reason = match serde_json::from_str::(&body) { - Ok(err) => format!("workload client_assertion rejected: {}", err.error), - Err(_) => format!("workload client_assertion rejected (HTTP {status})"), - }; - return Err(PluginViolation::new("delegation.idp_rejected", reason)); - } - - match response.json::().await { - Ok(parsed) => Ok(parsed.access_token), - Err(e) => Err(PluginViolation::new( - "delegation.bad_response", - format!("workload client_assertion response wasn't valid token JSON: {e}"), - )), - } - } -} - -/// Subset of the RFC 8693 response we care about. -#[derive(Debug, Deserialize)] -struct TokenExchangeResponse { - access_token: String, - /// Optional per RFC — defaults to `access_token` issued type. - #[serde(default)] - issued_token_type: Option, - /// Optional in RFC; many IdPs send it. - #[serde(default)] - expires_in: Option, - /// Space-separated effective scopes the IdP actually granted. - /// May be narrower than what we requested. - #[serde(default)] - scope: Option, -} - -/// Subset of the standard OAuth error response — `error` is the -/// machine-readable code (`invalid_grant`, `invalid_scope`, …). -#[derive(Debug, Deserialize)] -struct TokenErrorResponse { - error: String, - #[serde(default)] - error_description: Option, -} - -#[async_trait] -impl Plugin for OAuthDelegator { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for OAuthDelegator { - async fn handle( - &self, - payload: &DelegationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // `subject: this_workload` means *we* are the principal. There - // is no inbound credential to exchange — this instance's identity - // is its OAuth client identity, which it already proves via the - // Basic auth header below. The standard grant for "give me a - // token as myself" is client_credentials, not token exchange. - let as_this_workload = *payload.subject() == DelegationSubject::ThisWorkload; - - // `subject: caller_workload` means the calling agent acts as - // itself, and `bearer` is its JWT-SVID. An SVID is a *client - // credential*, not a `subject_token` — an authorization server - // won't accept it as an exchange subject — so leg 1 below trades - // it for an ordinary IdP token that the exchange (leg 2) can - // then scope down. - let is_workload = *payload.subject() == DelegationSubject::CallerWorkload; - - let bearer = payload.bearer_token(); - if bearer.is_empty() && !as_this_workload { - return PluginResult::deny(PluginViolation::new( - "delegation.bad_request", - "DelegationPayload carried an empty bearer_token — outbound \ - caller didn't populate the credential before invoking the hook", - )); - } - let audience = payload.target_audience().unwrap_or(""); - if audience.is_empty() { - return PluginResult::deny(PluginViolation::new( - "delegation.bad_request", - "target_audience missing — RFC 8693 token exchange requires \ - an audience to scope the minted credential", - )); - } - - let scope = Self::requested_scopes(payload); - - // Leg 1 (workload only): the SVID in `bearer` authenticates the - // agent as a client; mint the IdP base token here and let the - // exchange below run on it. Every other subject exchanges its - // own `bearer` directly. `Cow` avoids cloning the (already - // borrowed) bearer on the non-workload path. - let subject_token: Cow = if is_workload { - match self.mint_base_token(bearer).await { - Ok(token) => Cow::Owned(token), - Err(violation) => return PluginResult::deny(violation), - } - } else { - Cow::Borrowed(bearer) - }; - - // Build the form-encoded body: RFC 6749 §4.4 for this instance - // acting as itself, RFC 8693 §2.1 for every exchange on behalf - // of somebody else. - let mut form: Vec<(&str, &str)> = if as_this_workload { - vec![ - ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), - ("audience", audience), - ] - } else { - vec![ - ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), - // On the workload path this is the leg-1 base token, not - // the raw SVID; on every other path it's the caller's - // own bearer, unchanged. - ("subject_token", subject_token.as_ref()), - ("subject_token_type", &self.typed.subject_token_type), - ("audience", audience), - ] - }; - if !scope.is_empty() { - form.push(("scope", &scope)); - } - - // RFC 8693 §2.1 actor_token. Present only when the invoker - // attached one (sourced from the inbound SVID in - // `RawCredentialsExtension[CallerWorkload]`). Including it - // makes the IdP mint a token carrying `act` = actor alongside - // `sub` = subject — the delegation is recorded in the token - // itself. Absent, the exchange stays single-token. - // - // Skipped entirely under client_credentials: `actor_token` is - // a token-exchange parameter and has no meaning in RFC 6749 - // §4.4, so sending it would be malformed. A route that wants - // this instance as principal *and* the calling agent recorded in - // `act` needs a real subject credential for this instance — - // i.e. its own SVID — rather than client_credentials. - // - // Also skipped on the workload path: there the workload *is* the - // subject (via the leg-1 base token), so there is no separate - // actor to record. `actor_token` belongs to the on-behalf-of - // shape (a user subject with the calling agent as actor). - let actor_token = payload.actor_token(); - let actor_requested = !actor_token.is_empty() && !as_this_workload && !is_workload; - if actor_requested { - form.push(("actor_token", actor_token)); - form.push(("actor_token_type", &self.typed.actor_token_type)); - } - - // POST to the IdP. Basic auth carries our client credentials. - let response = match self - .http - .post(&self.typed.token_endpoint) - .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) - .form(&form) - .send() - .await - { - Ok(r) => r, - Err(e) if e.is_timeout() => { - return PluginResult::deny(PluginViolation::new( - "delegation.idp_timeout", - format!("token-exchange to {} timed out", self.typed.token_endpoint), - )); - }, - Err(e) => { - return PluginResult::deny(PluginViolation::new( - "delegation.idp_unreachable", - format!( - "token-exchange POST to {} failed: {e}", - self.typed.token_endpoint, - ), - )); - }, - }; - - let status = response.status(); - if !status.is_success() { - // Try to surface the standard `error` / `error_description` - // fields from the IdP. Fall back to status code. - let body = response.text().await.unwrap_or_default(); - let (code, reason) = match serde_json::from_str::(&body) { - Ok(err) => { - let mut reason = err.error.clone(); - if let Some(desc) = err.error_description { - reason.push_str(": "); - reason.push_str(&desc); - } - ("delegation.idp_rejected", reason) - }, - Err(_) => ( - "delegation.idp_rejected", - format!("IdP returned {status}: {body}"), - ), - }; - return PluginResult::deny(PluginViolation::new(code, reason)); - } - - let parsed = match response.json::().await { - Ok(p) => p, - Err(e) => { - return PluginResult::deny(PluginViolation::new( - "delegation.bad_response", - format!("IdP response wasn't valid token-exchange JSON: {e}"), - )); - }, - }; - - // Compute effective scopes. IdP's `scope` field wins (it - // reflects what was actually granted, possibly narrower - // than what we asked for); fall back to the requested set - // if the IdP didn't send one. - let effective_scopes: Vec = if let Some(s) = &parsed.scope { - s.split_whitespace().map(String::from).collect() - } else if !scope.is_empty() { - scope.split_whitespace().map(String::from).collect() - } else { - Vec::new() - }; - - // Enforce requested ⊆ effective. Without this check, a route - // that asked for `read write` and got back `read` would - // proceed as if the broader grant had succeeded — downstream - // calls would fail in policy-author-unobservable ways. We - // compare only when the IdP explicitly sent a `scope` field - // (otherwise we just used the requested set above, so the - // subset relationship is trivially true). The required - // permissions come straight off the DelegationPayload; route - // attenuation capabilities are advisory extras and not - // checked here. - if parsed.scope.is_some() { - let granted: std::collections::HashSet<&str> = - effective_scopes.iter().map(String::as_str).collect(); - let missing: Vec<&str> = payload - .required_permissions() - .iter() - .filter(|req| !granted.contains(req.as_str())) - .map(String::as_str) - .collect(); - if !missing.is_empty() { - return PluginResult::deny(PluginViolation::new( - "delegation.scope_too_broad", - format!( - "IdP granted narrower scopes than requested. \ - requested=[{}] granted=[{}] missing=[{}]", - payload.required_permissions().join(" "), - effective_scopes.join(" "), - missing.join(" "), - ), - )); - } - } - - // Compute expiry. Most IdPs send `expires_in` (seconds); - // if missing, default to 5 minutes — short enough that a - // misconfigured-but-no-expiry IdP doesn't mint long-lived - // tokens by accident. - let ttl_secs = parsed.expires_in.unwrap_or(300); - // Route attenuation may shorten further. - let ttl_secs = if let Some(att) = payload.route_attenuation() { - if let Some(hint) = att.ttl_seconds { - ttl_secs.min(hint as i64) - } else { - ttl_secs - } - } else { - ttl_secs - }; - let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs); - - // Best-effort interop check. We asked the IdP to record the calling - // agent in `act` (RFC 8693 delegation semantics). If the minted token - // is a JWT that carries no `act`, the IdP did impersonation instead — - // it accepted the exchange but silently dropped the actor (Keycloak's - // Standard Token Exchange behaves this way). The scoped token is still - // valid and returned; we only surface the gap so it isn't a silent - // no-op the policy author never notices. - // - // Throttled to once per delegator via `warned_missing_act`: the - // `!load` short-circuit skips the per-request JWT decode entirely - // once we've warned, so a token service that always drops `act` - // doesn't spend a decode + a log line on every request. - use std::sync::atomic::Ordering; - if actor_requested - && !self.warned_missing_act.load(Ordering::Relaxed) - && jwt_payload_omits_act(&parsed.access_token) - && !self.warned_missing_act.swap(true, Ordering::Relaxed) - { - tracing::warn!( - target: "cpex::delegation", - token_endpoint = %self.typed.token_endpoint, - "actor was requested (RFC 8693 actor_token) but the minted token carries no `act` claim; \ - the token service may implement impersonation only (e.g. Keycloak Standard Token Exchange) \ - and ignored the actor — the acting agent will not appear downstream. \ - (Further occurrences on this delegator are suppressed.)", - ); - } - - let token = RawDelegatedToken::new( - parsed.access_token, - self.typed.default_outbound_header.clone(), - audience.to_string(), - effective_scopes, - expires_at, - ); - - let mut updated = payload.clone(); - updated.delegated_token = Some(token); - updated.delegation_mode = Some(payload.subject().default_mode()); - updated.minted_at = Some(Utc::now()); - if let Some(issued) = parsed.issued_token_type { - updated.metadata.insert( - "issued_token_type".into(), - serde_json::Value::String(issued), - ); - } else { - updated.metadata.insert( - "issued_token_type".into(), - serde_json::Value::String(DEFAULT_ISSUED_TOKEN_TYPE.into()), - ); - } - - PluginResult::modify_payload(updated) - } -} - -/// Best-effort: does `access_token` decode as a JWT whose payload has no -/// `act` claim? Returns `true` only when we can *positively* see a JWT -/// payload object that lacks `act`. Anything we can't inspect — an opaque -/// token, a non-base64url segment, a non-JSON payload — returns `false`, -/// so a caller using this to warn never fires on a token it couldn't read. -/// -/// The signature is deliberately not verified: this token just came back -/// from our own trusted IdP roundtrip, and we're only reading a claim to -/// decide whether to log, not making a trust decision. -fn jwt_payload_omits_act(access_token: &str) -> bool { - use base64::Engine as _; - // JWT is `header.payload.signature`; the claims are the middle segment. - let Some(payload_b64) = access_token.split('.').nth(1) else { - return false; - }; - let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) else { - return false; - }; - let Ok(claims) = serde_json::from_slice::(&bytes) else { - return false; - }; - // Treat both a missing `act` and an explicit `"act": null` as absent — - // a null claim records no actor. - claims.is_object() && claims.get("act").map_or(true, serde_json::Value::is_null) -} - -// Silence unused-import warning when only a subset of these is -// reached in any given config path. Kept as a single place so the -// crate's surface is visible at a glance. -#[allow(dead_code)] -fn _force_link(_: Arc<()>) {} - -/// Reject `http://` for endpoints that carry credentials. Allows -/// `https://` unconditionally and `http://` only when the operator -/// explicitly set `insecure_http: true`. Empty / un-parseable URLs -/// are returned as-is to whatever validator already exists upstream -/// — this helper only owns the scheme check. -/// -/// Returns a short fragment ("must use https://…") that the caller -/// prepends with the field name + plugin name for the full error -/// message. -fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { - let lowered = url.trim_start().to_ascii_lowercase(); - if lowered.starts_with("https://") { - return Ok(()); - } - if lowered.starts_with("http://") { - if insecure_http { - return Ok(()); - } - return Err(format!( - "must use https:// (got '{url}'). Set `insecure_http: true` \ - to allow plaintext for localhost/dev only — never production." - )); - } - // Anything else (missing scheme, bad scheme): defer to the - // upstream URL parser. We're not the URL validator, just the - // scheme gate. - Ok(()) -} - -#[cfg(test)] -mod scheme_tests { - use super::require_https; - - #[test] - fn https_always_ok() { - assert!(require_https("https://idp.example/oauth/token", false).is_ok()); - assert!(require_https("HTTPS://IDP.EXAMPLE/", false).is_ok()); - } - - #[test] - fn http_default_rejected() { - let err = require_https("http://localhost:8081/oauth/token", false).unwrap_err(); - assert!(err.contains("must use https"), "{}", err); - assert!(err.contains("insecure_http"), "mentions opt-out: {}", err); - } - - #[test] - fn http_with_explicit_opt_in_allowed() { - assert!(require_https("http://localhost:8081/oauth/token", true).is_ok()); - } - - #[test] - fn http_with_leading_whitespace_still_rejected() { - // A trailing newline or leading whitespace from sloppy YAML - // shouldn't smuggle a plaintext URL past the gate. - let err = require_https(" http://idp/", false).unwrap_err(); - assert!(err.contains("must use https")); - } -} - -#[cfg(test)] -mod act_claim_tests { - use super::jwt_payload_omits_act; - use base64::Engine as _; - - // Build a `header.payload.sig` JWT string from a payload JSON literal. - fn jwt(payload: &str) -> String { - let b = |s: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(s.as_bytes()); - format!("{}.{}.{}", b(r#"{"alg":"none"}"#), b(payload), "sig") - } - - #[test] - fn payload_with_act_is_not_flagged() { - // Delegation honored: `act` present → no warning. - let token = jwt(r#"{"sub":"user","act":{"sub":"agent"}}"#); - assert!(!jwt_payload_omits_act(&token)); - } - - #[test] - fn payload_without_act_is_flagged() { - // Impersonation: subject only, no `act` → this is the case we warn on. - let token = jwt(r#"{"sub":"user","aud":"workday-api"}"#); - assert!(jwt_payload_omits_act(&token)); - } - - #[test] - fn payload_with_null_act_is_flagged() { - // `"act": null` records no actor — treated as absent, same as missing. - let token = jwt(r#"{"sub":"user","act":null}"#); - assert!(jwt_payload_omits_act(&token)); - } - - #[test] - fn opaque_token_is_not_flagged() { - // Not a JWT (no dots): we can't inspect it, so never warn. - assert!(!jwt_payload_omits_act("opaque-reference-token")); - } - - #[test] - fn non_base64_payload_is_not_flagged() { - // Right shape, but the middle segment isn't valid base64url. - assert!(!jwt_payload_omits_act("aaa.!!!not-base64!!!.sig")); - } - - #[test] - fn non_json_payload_is_not_flagged() { - // Decodes as base64url but isn't JSON claims — can't tell, don't warn. - let b = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json"); - assert!(!jwt_payload_omits_act(&format!("aaa.{b}.sig"))); - } -} diff --git a/builtins/plugins/delegator-oauth/src/factory.rs b/builtins/plugins/delegator-oauth/src/factory.rs deleted file mode 100644 index 128f738a..00000000 --- a/builtins/plugins/delegator-oauth/src/factory.rs +++ /dev/null @@ -1,59 +0,0 @@ -// Location: ./builtins/plugins/delegator-oauth/src/factory.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `PluginFactory` impl for the OAuth 2.0 (RFC 8693) token-exchange -// delegator. Lives here (alongside the delegator) so every host — -// Praxis filter, Envoy bridge, CLI runner, test harness — wires it -// up the same way. -// -// Operators declare it in CPEX YAML as: -// -// plugins: -// - name: workday-oauth -// kind: delegator/oauth -// hooks: [token.delegate] -// config: -// token_endpoint: https://idp.example.com/token -// client_id: praxis-cpex -// client_secret_source: { kind: env, var: OAUTH_CLIENT_SECRET } -// -// The `kind: delegator/oauth` string is part of this crate's public -// API. Hosts call -// `mgr.register_factory("delegator/oauth", Box::new(OAuthDelegatorFactory))` -// before `load_config_yaml`. - -use std::sync::Arc; - -use cpex_core::{ - delegation::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}, - error::PluginError, - factory::{PluginFactory, PluginInstance}, - hooks::TypedHandlerAdapter, - plugin::PluginConfig, -}; - -use crate::OAuthDelegator; - -/// The plugin `kind:` string operators write in CPEX YAML to declare -/// an OAuth RFC 8693 token-exchange delegator. -pub const KIND: &str = "delegator/oauth"; - -/// Factory for `kind: delegator/oauth` plugins. Instantiates an -/// `OAuthDelegator` from the `config:` block and registers it on the -/// `token.delegate` hook. -pub struct OAuthDelegatorFactory; - -impl PluginFactory for OAuthDelegatorFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let delegator = Arc::new(OAuthDelegator::new(config.clone())?); - let handler = Arc::new(TypedHandlerAdapter::::new( - Arc::clone(&delegator), - )); - Ok(PluginInstance { - plugin: delegator, - handlers: vec![(HOOK_TOKEN_DELEGATE, handler)], - }) - } -} diff --git a/builtins/plugins/delegator-oauth/src/lib.rs b/builtins/plugins/delegator-oauth/src/lib.rs deleted file mode 100644 index 80cb9c7b..00000000 --- a/builtins/plugins/delegator-oauth/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Location: ./builtins/plugins/delegator-oauth/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-plugin-delegator-oauth — `TokenDelegateHandler` backed by RFC 8693 -// OAuth 2.0 Token Exchange. -// -// The host registers this handler against `token.delegate`; outbound -// forwarding plugins invoke `mgr.invoke_named::(...)` -// with a `DelegationPayload` (caller's bearer token + target -// audience + required scopes); this handler POSTs to the configured -// OAuth server's token endpoint with `grant_type=urn:ietf:params: -// oauth:grant-type:token-exchange` and the appropriate -// `subject_token` / `audience` / `scope` parameters; the response's -// `access_token` becomes the `RawDelegatedToken` the framework -// stashes under `Extensions.raw_credentials.delegated_tokens`. -// -// Scope: data shapes + module structure only. Actual HTTP exchange -// logic and mock-IdP integration tests land later. - -pub mod config; -pub mod delegator; -pub mod factory; - -pub use config::{ClientSecretSource, OAuthDelegatorConfig}; -pub use delegator::OAuthDelegator; -pub use factory::{OAuthDelegatorFactory, KIND}; diff --git a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs deleted file mode 100644 index 23b9ffe4..00000000 --- a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs +++ /dev/null @@ -1,730 +0,0 @@ -// Location: ./builtins/plugins/delegator-oauth/tests/oauth_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end tests for `OAuthDelegator` against a `mockito`-backed -// fake IdP. Exercises the full handler path: -// `mgr.invoke_named::(...)` → delegator builds -// RFC 8693 form body → POSTs to mock IdP → mock returns response -// → delegator translates into a `RawDelegatedToken` → host -// extracts via `from_pipeline_result`. -// -// Scenarios: -// * happy path — minted token populated with audience + scopes + expiry -// * IdP returns 400 with `invalid_grant` — surfaces `delegation.idp_rejected` -// * IdP unreachable — surfaces `delegation.idp_unreachable` -// * Request body shape — mockito's matcher verifies we send the -// correct RFC 8693 fields -// * actor_token — present on the wire when the payload carries one -// (Mode B), fully absent when it doesn't -// * workload subject (Mode A) — the SVID authenticates the agent as -// a client_assertion (leg 1), then the exchange runs on that base -// token (leg 2); attributed `AsCallerWorkload`, not `AsThisWorkload` - -use std::sync::Arc; - -use cpex_core::delegation::{ - AttenuationConfig, AuthEnforcedBy, DelegationPayload, DelegationSubject, TargetType, - TokenDelegateHook, HOOK_TOKEN_DELEGATE, -}; -use cpex_core::extensions::raw_credentials::{DelegationMode, TokenRole}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; - -use cpex_plugin_delegator_oauth::OAuthDelegator; - -use mockito::{Matcher, Server}; -use serde_json::json; - -// ===================================================================== -// Fixtures -// ===================================================================== - -fn plugin_config(token_endpoint: &str) -> PluginConfig { - PluginConfig { - name: "oauth-delegator".into(), - kind: "test".into(), - hooks: vec![HOOK_TOKEN_DELEGATE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(json!({ - "token_endpoint": token_endpoint, - "client_id": "gateway-client", - "client_secret_source": { - "kind": "literal", - "secret": "test-secret", - }, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "timeout_seconds": 2, - "default_outbound_header": "Authorization", - // wiremock binds to http://127.0.0.1 — opt in to plaintext - // for the test. Production deployments must omit this. - "insecure_http": true, - })), - ..Default::default() - } -} - -fn build_payload(target: &str, audience: &str, scopes: &[&str]) -> DelegationPayload { - DelegationPayload::new("caller-bearer-token-bytes", target) - .with_target_type(TargetType::Tool) - .with_target_audience(audience) - .with_required_permissions(scopes.iter().map(|s| s.to_string()).collect()) - .with_auth_enforced_by(AuthEnforcedBy::Target) - .with_route_attenuation(AttenuationConfig { - capabilities: vec!["audit".into()], - resource_template: None, - actions: Vec::new(), - ttl_seconds: Some(120), - }) -} - -async fn build_manager(token_endpoint: &str) -> Arc { - let cfg = plugin_config(token_endpoint); - let delegator = OAuthDelegator::new(cfg.clone()).expect("delegator constructs"); - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::new(delegator), - cfg, - &[HOOK_TOKEN_DELEGATE], - ) - .unwrap(); - mgr.initialize().await.unwrap(); - mgr -} - -async fn invoke( - mgr: &Arc, - payload: DelegationPayload, -) -> cpex_core::executor::PipelineResult { - let (result, _bg) = mgr - .invoke_named::( - HOOK_TOKEN_DELEGATE, - payload, - Extensions::default(), - None, - ) - .await; - result -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Happy path: mock IdP responds with a fresh access_token; the -/// delegator translates it into a `RawDelegatedToken` populated -/// with the requested audience, the effective scopes, and an -/// expiry derived from `expires_in`. -#[tokio::test] -async fn happy_path_mints_delegated_token() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .match_header("content-type", "application/x-www-form-urlencoded") - // Expect the form fields RFC 8693 requires. - .match_body(Matcher::AllOf(vec![ - Matcher::UrlEncoded( - "grant_type".into(), - "urn:ietf:params:oauth:grant-type:token-exchange".into(), - ), - Matcher::UrlEncoded( - "subject_token".into(), - "caller-bearer-token-bytes".into(), - ), - Matcher::UrlEncoded( - "audience".into(), - "https://hr.example.com".into(), - ), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - json!({ - "access_token": "minted-downstream-jwt", - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "expires_in": 300, - "scope": "read:compensation audit", - }) - .to_string(), - ) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "happy path should mint a token: violation = {:?}", - result.violation, - ); - - let final_payload = DelegationPayload::from_pipeline_result(&result) - .expect("delegation payload should be present"); - let token = final_payload - .delegated_token - .as_ref() - .expect("delegated_token populated"); - - assert_eq!(&*token.token, "minted-downstream-jwt"); - assert_eq!(token.audience, "https://hr.example.com"); - assert_eq!(token.outbound_header, "Authorization"); - // Effective scopes come from the IdP's `scope` field. - assert!(token.scopes.contains(&"read:compensation".to_string())); - assert!(token.scopes.contains(&"audit".to_string())); - - // Mode is OnBehalfOfUser by default for RFC 8693 exchange. - assert!(matches!( - final_payload.delegation_mode, - Some(DelegationMode::OnBehalfOfUser), - )); - - // TTL respects the route hint (120s) — IdP's expires_in was 300, - // but the route asked to cap at 120, so effective is 120. - let ttl_left = (token.expires_at - chrono::Utc::now()).num_seconds(); - assert!( - ttl_left <= 120 && ttl_left > 100, - "ttl should reflect min(idp_ttl, route_hint); got {ttl_left}s", - ); - - mock.assert_async().await; -} - -/// IdP returns a 400 with the standard `error` / `error_description` -/// shape — delegator surfaces `delegation.idp_rejected` carrying the -/// IdP's machine-readable code. -#[tokio::test] -async fn idp_rejection_surfaces_error_code() { - let mut server = Server::new_async().await; - server - .mock("POST", "/oauth/token") - .with_status(400) - .with_header("content-type", "application/json") - .with_body( - json!({ - "error": "invalid_grant", - "error_description": "subject_token is not active", - }) - .to_string(), - ) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload("tool", "https://downstream.example.com", &["read"]); - - let result = invoke(&mgr, payload).await; - assert!(!result.continue_processing); - let violation = result.violation.expect("rejection should surface"); - assert_eq!(violation.code, "delegation.idp_rejected"); - assert!( - violation.reason.contains("invalid_grant"), - "reason should include IdP's error code; got: {}", - violation.reason, - ); - assert!( - violation.reason.contains("not active"), - "reason should include the error_description; got: {}", - violation.reason, - ); -} - -/// IdP unreachable (mockito server stopped) — delegator surfaces -/// `delegation.idp_unreachable` rather than panicking. -#[tokio::test] -async fn idp_unreachable_surfaces_violation() { - // Use a localhost URL that should be unreachable (no listener - // on that port). The `127.0.0.1:1` port-1 trick: port 1 isn't - // bound by typical systems and connection refusal is fast. - let mgr = build_manager("http://127.0.0.1:1/oauth/token").await; - let payload = build_payload("tool", "https://downstream.example.com", &["read"]); - - let result = invoke(&mgr, payload).await; - assert!(!result.continue_processing); - let violation = result.violation.expect("rejection should surface"); - // Either `idp_unreachable` (connection refused) or `idp_timeout` - // (if the OS decides to slow-fail) — both are valid outcomes - // for "IdP isn't there." The test accepts either. - assert!( - violation.code == "delegation.idp_unreachable" - || violation.code == "delegation.idp_timeout", - "expected idp_unreachable or idp_timeout; got {}", - violation.code, - ); -} - -/// Empty bearer token — fails fast at the handler entry before -/// touching the network. Verifies the input-validation path. -#[tokio::test] -async fn empty_bearer_token_rejects_without_network() { - let mgr = build_manager("http://this-must-not-be-called/oauth/token").await; - let payload = - DelegationPayload::new("", "tool").with_target_audience("https://downstream.example.com"); - - let result = invoke(&mgr, payload).await; - assert!(!result.continue_processing); - let violation = result.violation.expect("rejection should surface"); - assert_eq!(violation.code, "delegation.bad_request"); - assert!(violation.reason.contains("empty bearer_token")); -} - -/// Missing target audience — fails fast (RFC 8693 requires -/// `audience` for downstream scoping). -#[tokio::test] -async fn missing_audience_rejects_without_network() { - let mgr = build_manager("http://this-must-not-be-called/oauth/token").await; - let payload = DelegationPayload::new("some-token", "tool"); // no audience - - let result = invoke(&mgr, payload).await; - assert!(!result.continue_processing); - let violation = result.violation.expect("rejection should surface"); - assert_eq!(violation.code, "delegation.bad_request"); - assert!(violation.reason.contains("target_audience")); -} - -/// IdP grants narrower scopes than requested — delegator emits the -/// documented `delegation.scope_too_broad` code rather than silently -/// proceeding. Without this check, a route that requested -/// `read+write` and got back only `read` would mint a token the -/// downstream call can't actually use, leaving the policy author -/// with no observable signal about *why* the call failed downstream. -#[tokio::test] -async fn idp_narrower_scope_surfaces_scope_too_broad() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - json!({ - "access_token": "narrower-token", - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "expires_in": 300, - // Asked for both, got only `read`. - "scope": "read", - }) - .to_string(), - ) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload("tool", "https://downstream.example.com", &["read", "write"]); - - let result = invoke(&mgr, payload).await; - assert!( - !result.continue_processing, - "narrower IdP grant must NOT silently succeed", - ); - let violation = result.violation.expect("rejection should surface"); - assert_eq!(violation.code, "delegation.scope_too_broad"); - assert!( - violation.reason.contains("write"), - "reason should name the missing scope: {}", - violation.reason, - ); - - mock.assert_async().await; -} - -/// Sanity check: when the IdP grants exactly the requested set, the -/// scope check passes. Pins the "no false positive" half of the -/// scope_too_broad behaviour. -#[tokio::test] -async fn idp_exact_scope_match_succeeds() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - json!({ - "access_token": "ok-token", - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "expires_in": 300, - "scope": "read write", - }) - .to_string(), - ) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload("tool", "https://downstream.example.com", &["read", "write"]); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "exact scope match should mint a token; violation = {:?}", - result.violation, - ); - mock.assert_async().await; -} - -// ===================================================================== -// RFC 8693 actor_token / subject-role attribution -// ===================================================================== - -/// Standard 200 response body, factored out so the actor tests can -/// focus on what they're actually asserting (the request side). -fn ok_token_response() -> String { - json!({ - "access_token": "minted-downstream-jwt", - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "expires_in": 300, - "scope": "read:compensation", - }) - .to_string() -} - -/// Mode B — user subject + workload actor. The delegator must put the -/// SVID on the wire as RFC 8693 §2.1 `actor_token`, tagged with the -/// configured `actor_token_type`, alongside the user's `subject_token`. -/// This is the on-behalf-of-a-user shape, and the -/// minted token still speaks for the user. -#[tokio::test] -async fn actor_token_reaches_the_idp_when_the_payload_carries_one() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .match_body(Matcher::AllOf(vec![ - // The user is still the subject... - Matcher::UrlEncoded("subject_token".into(), "caller-bearer-token-bytes".into()), - // ...and the workload SVID rides along as the actor. - Matcher::UrlEncoded("actor_token".into(), "workload.svid.bytes".into()), - Matcher::UrlEncoded( - "actor_token_type".into(), - "urn:ietf:params:oauth:token-type:jwt".into(), - ), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(ok_token_response()) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ) - .with_actor(TokenRole::CallerWorkload, "workload.svid.bytes"); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "actor-token exchange should mint a token; violation = {:?}", - result.violation, - ); - - let final_payload = DelegationPayload::from_pipeline_result(&result) - .expect("delegation payload should be present"); - // Subject is the user, so the token still speaks for the user - // even though a workload actor was recorded. - assert!(matches!( - final_payload.delegation_mode, - Some(DelegationMode::OnBehalfOfUser), - )); - - // If the actor fields hadn't been sent, the matcher above would - // have failed to match and this assertion would fire. - mock.assert_async().await; -} - -/// The negative half: a payload with no actor must produce a plain -/// single-token exchange. Asserted by rejecting any request whose body -/// mentions `actor_token` at all — a stray empty `actor_token=` field -/// would confuse strict IdPs, so "absent" has to mean absent. -#[tokio::test] -async fn absent_actor_leaves_no_actor_fields_on_the_wire() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .match_request(|req| { - let body = req.body().expect("request has a body"); - !String::from_utf8_lossy(body).contains("actor_token") - }) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(ok_token_response()) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - // No `.with_actor_token(...)` — the ordinary single-token case. - let payload = build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "single-token exchange should still succeed; violation = {:?}", - result.violation, - ); - mock.assert_async().await; -} - -/// `subject: this_workload` — this instance holds the access to the -/// downstream (the "hold the tool credentials here" deployment) -/// and calls it as itself. There is no inbound credential to -/// exchange, so this must switch to an RFC 6749 §4.4 -/// `client_credentials` grant rather than a token exchange: no -/// `subject_token`, and this instance's identity proven by the Basic -/// auth header it already sends. -#[tokio::test] -async fn this_workload_subject_uses_client_credentials_not_token_exchange() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .match_body(Matcher::AllOf(vec![ - Matcher::UrlEncoded("grant_type".into(), "client_credentials".into()), - Matcher::UrlEncoded("audience".into(), "https://hr.example.com".into()), - ])) - // A token exchange sends subject_token; this must not. - .match_request(|req| { - let body = req.body().expect("request has a body"); - !String::from_utf8_lossy(body).contains("subject_token") - }) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(ok_token_response()) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - // Note the empty bearer token: for a this_workload subject that is the - // expected state, not the "caller forgot the credential" error. - let payload = DelegationPayload::new("", "get_compensation") - .with_subject(DelegationSubject::ThisWorkload) - .with_target_audience("https://hr.example.com") - .with_required_permissions(vec!["read:compensation".into()]); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "this_workload-subject exchange should mint a token; violation = {:?}", - result.violation, - ); - - let final_payload = DelegationPayload::from_pipeline_result(&result) - .expect("delegation payload should be present"); - assert!( - matches!( - final_payload.delegation_mode, - Some(DelegationMode::AsThisWorkload), - ), - "this_workload subject must be attributed to this instance, got {:?}", - final_payload.delegation_mode, - ); - mock.assert_async().await; -} - -/// An empty bearer token is still an error for every subject that -/// *does* have an inbound credential. Pins the boundary: the -/// this_workload's exemption must not silently swallow a genuinely missing -/// workload or user token. -#[tokio::test] -async fn empty_bearer_still_rejected_for_non_this_workload_subjects() { - let mgr = build_manager("https://unused.example.com/oauth/token").await; - let payload = DelegationPayload::new("", "get_compensation") - .with_subject(DelegationSubject::CallerWorkload) - .with_target_audience("https://hr.example.com"); - - let result = invoke(&mgr, payload).await; - assert!( - !result.continue_processing, - "a missing credential must still be an error for a workload subject", - ); - assert_eq!( - result.violation.expect("violation surfaced").code, - "delegation.bad_request", - ); -} - -/// `actor_token` is a token-exchange parameter with no meaning under -/// `client_credentials`, so a this_workload-subject call must not send it -/// even when the payload carries one — an IdP receiving both would be -/// getting a malformed request. -#[tokio::test] -async fn this_workload_subject_never_sends_actor_token() { - let mut server = Server::new_async().await; - let mock = server - .mock("POST", "/oauth/token") - .match_request(|req| { - let body = req.body().expect("request has a body"); - !String::from_utf8_lossy(body).contains("actor_token") - }) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(ok_token_response()) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = DelegationPayload::new("", "get_compensation") - .with_subject(DelegationSubject::ThisWorkload) - .with_actor(TokenRole::CallerWorkload, "workload.svid.bytes") - .with_target_audience("https://hr.example.com") - .with_required_permissions(vec!["read:compensation".into()]); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "should still mint; violation = {:?}", - result.violation, - ); - mock.assert_async().await; -} - -/// Mode A — the calling agent acts as itself. Its SVID is a *client -/// credential*, not a subject_token, so the delegator runs two legs: -/// -/// leg 1 present the SVID as an RFC 7523 `client_assertion` -/// (client_credentials) → the agent's base IdP token; -/// leg 2 the ordinary exchange, run on that BASE token, scopes it -/// to the target audience. -/// -/// This is what keeps this instance (holder of the leg-2 client secret), -/// not the agent, as the grantor of downstream authority. The minted -/// credential still speaks for the agent, so `delegation_mode` must be -/// `AsCallerWorkload` (the delegated-token cache keys off it) — and -/// specifically not `AsThisWorkload`, which is this instance's own identity. -/// -/// Both legs are asserted: proving the SVID went out as a -/// `client_assertion` in leg 1 and that leg 2 exchanged the base token -/// — never the raw SVID as a subject_token. -#[tokio::test] -async fn workload_subject_authenticates_by_svid_then_exchanges() { - let mut server = Server::new_async().await; - - // Leg 1: SVID as client_assertion (jwt-spiffe) under - // client_credentials → the agent's base token. Must NOT be a - // subject_token here. - let leg1 = server - .mock("POST", "/oauth/token") - .match_body(Matcher::AllOf(vec![ - Matcher::UrlEncoded("grant_type".into(), "client_credentials".into()), - Matcher::UrlEncoded( - "client_assertion_type".into(), - "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe".into(), - ), - Matcher::UrlEncoded( - "client_assertion".into(), - "caller-bearer-token-bytes".into(), - ), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(json!({ "access_token": "agent-base-token", "expires_in": 300 }).to_string()) - .create_async() - .await; - - // Leg 2: the exchange runs on the BASE token from leg 1, not the - // SVID. Pinning subject_token here is what fails if leg 1 is - // skipped or the raw SVID leaks through as the subject. - let leg2 = server - .mock("POST", "/oauth/token") - .match_body(Matcher::AllOf(vec![ - Matcher::UrlEncoded( - "grant_type".into(), - "urn:ietf:params:oauth:grant-type:token-exchange".into(), - ), - Matcher::UrlEncoded("subject_token".into(), "agent-base-token".into()), - Matcher::UrlEncoded("audience".into(), "https://hr.example.com".into()), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(ok_token_response()) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ) - .with_subject(DelegationSubject::CallerWorkload); - - let result = invoke(&mgr, payload).await; - assert!( - result.continue_processing, - "two-leg workload delegation should mint a token; violation = {:?}", - result.violation, - ); - - let final_payload = DelegationPayload::from_pipeline_result(&result) - .expect("delegation payload should be present"); - assert!( - matches!( - final_payload.delegation_mode, - Some(DelegationMode::AsCallerWorkload), - ), - "workload subject must be attributed to the calling agent, got {:?}", - final_payload.delegation_mode, - ); - - // Both legs actually fired — the SVID authenticated the agent (leg - // 1) and the exchange ran on the base token (leg 2). - leg1.assert_async().await; - leg2.assert_async().await; -} - -/// A leg-1 rejection must not echo submitted credential material. Even -/// when the IdP hostilely parrots the SVID back in `error_description`, the -/// caller-visible violation carries only the OAuth error code — never the -/// `client_assertion` bytes. -#[tokio::test] -async fn leg1_rejection_does_not_leak_the_client_assertion() { - let mut server = Server::new_async().await; - - let _leg1 = server - .mock("POST", "/oauth/token") - .with_status(400) - .with_header("content-type", "application/json") - .with_body( - json!({ - "error": "invalid_client", - "error_description": "assertion 'caller-bearer-token-bytes' is not valid", - }) - .to_string(), - ) - .create_async() - .await; - - let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ) - .with_subject(DelegationSubject::CallerWorkload); - - let result = invoke(&mgr, payload).await; - assert!(!result.continue_processing, "leg-1 rejection should deny"); - let violation = result.violation.expect("violation surfaced"); - assert_eq!(violation.code, "delegation.idp_rejected"); - assert!( - !violation.reason.contains("caller-bearer-token-bytes"), - "violation must NOT echo the submitted SVID; got: {}", - violation.reason, - ); - assert!( - violation.reason.contains("invalid_client"), - "violation should carry the OAuth error code; got: {}", - violation.reason, - ); -} diff --git a/builtins/plugins/elicitation-ciba/Cargo.toml b/builtins/plugins/elicitation-ciba/Cargo.toml deleted file mode 100644 index 73dbb2f1..00000000 --- a/builtins/plugins/elicitation-ciba/Cargo.toml +++ /dev/null @@ -1,71 +0,0 @@ -# Location: ./builtins/plugins/elicitation-ciba/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-plugin-elicitation-ciba — `ElicitationHandler` that drives manager -# approval (and other human-in-the-loop kinds) through OpenID Connect -# CIBA (Client-Initiated Backchannel Authentication) against Keycloak or -# any CIBA-capable OP. -# -# # Why this exists -# -# The `ElicitationHook` family defines the surface (`ElicitationPayload` -# in/out, three operations); this crate is the *backend* that actually -# reaches a human. The CIBA flow: -# -# * dispatch → POST the backchannel auth endpoint with `login_hint` -# (the approver), `binding_message` (the audited purpose), and -# `scope`; the OP pushes a decoupled approval prompt to the -# approver's authentication device and returns an `auth_req_id`. -# * check → POST the token endpoint with -# `grant_type=urn:openid:params:grant-type:ciba` + `auth_req_id`; -# `authorization_pending` / `slow_down` mean keep waiting, a token -# means approved, `access_denied` / `expired_token` are terminal. -# * validate → confirm the returned token names the expected approver. -# -# The hours-long human gap is owned by the OP, never by a handler call — -# each of the three operations is a short, synchronous HTTP round-trip. - -[package] -name = "cpex-plugin-elicitation-ciba" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX elicitation handler — OIDC CIBA human-in-the-loop approval." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -cpex-core = { workspace = true } - -# `reqwest` for the backchannel + token-endpoint POSTs. Same TLS stance -# as cpex-plugin-delegator-oauth: rustls, no native-tls/openssl link. -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } - -# `base64` to decode the JWT payload segment for the approver claim -# (claims extraction only — see the validate notes in approver.rs). -base64 = "0.22" - -async-trait = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["sync"] } -chrono = { workspace = true } - -# Secret-clearing wrapper for the OAuth client secret in memory. -zeroize = { version = "1.8", features = ["zeroize_derive"] } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } -serde_json = { workspace = true } -# `mockito` stands up an HTTP server in-process so tests can assert the -# CIBA request shapes and simulate OP responses without a real Keycloak. -mockito = "1" - -[lints] -workspace = true diff --git a/builtins/plugins/elicitation-ciba/src/approver.rs b/builtins/plugins/elicitation-ciba/src/approver.rs deleted file mode 100644 index 27545309..00000000 --- a/builtins/plugins/elicitation-ciba/src/approver.rs +++ /dev/null @@ -1,570 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/src/approver.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CibaApprover` — `HookHandler` that reaches a human -// through OIDC CIBA. One `handle` entry point dispatches on -// `ElicitationPayload::operation` to the three short, synchronous -// round-trips: -// -// * dispatch → backchannel auth POST (`login_hint` / `binding_message` -// / `scope`) → `auth_req_id` (used as the elicitation id). -// * check → token-endpoint poll (`grant_type=...:ciba` + -// `auth_req_id`) → pending / approved / denied / expired. On -// approval, extract the approver claim from the OP token and -// store *that* (never the token — see store.rs). -// * validate → cross-check the resolved approver (stored at check) -// against the expected `login_hint`. -// -// # Error handling -// -// Construction errors → `Box` (`PluginError::Config`). -// Runtime *failures* (network, OP rejection, malformed response) → -// `PluginResult::deny(PluginViolation::new(code, reason))`, which the -// apl-cpex bridge maps to an `ElicitationError` and the apl-core -// evaluator then routes through the step's `on_error`. Normal lifecycle -// *states* (pending / approved / denied / expired) are NOT failures — -// they're returned as data on the payload via `modify_payload`. - -use std::sync::Arc; - -use async_trait::async_trait; -use base64::Engine; -use chrono::Utc; -use serde::Deserialize; -use zeroize::Zeroizing; - -use cpex_core::context::PluginContext; -use cpex_core::elicitation::{ - ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, - ElicitationStatusKind, -}; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use crate::config::{require_https, CibaConfig}; -use crate::store::{Correlation, CorrelationStore, InMemoryCorrelationStore}; - -/// OIDC CIBA grant type for the token-endpoint poll. -const GRANT_TYPE_CIBA: &str = "urn:openid:params:grant-type:ciba"; - -/// CIBA `ElicitationHook` handler. -pub struct CibaApprover { - cfg: PluginConfig, - typed: CibaConfig, - client_secret: Zeroizing, - http: reqwest::Client, - store: Arc, -} - -impl std::fmt::Debug for CibaApprover { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CibaApprover") - .field("plugin", &self.cfg.name) - .field("backchannel_endpoint", &self.typed.backchannel_endpoint) - .field("client_id", &self.typed.client_id) - .field("client_secret", &"") - .finish() - } -} - -impl CibaApprover { - /// Build from a `PluginConfig`: parse `cfg.config` into [`CibaConfig`], - /// validate endpoints (https unless `insecure_http`), resolve the - /// client secret, and build the shared HTTP client. - pub fn new(cfg: PluginConfig) -> Result> { - let raw = cfg - .config - .as_ref() - .ok_or_else(|| cfg_err(&cfg.name, "requires a `config:` block".to_string()))?; - let typed: CibaConfig = serde_json::from_value(raw.clone()) - .map_err(|e| cfg_err(&cfg.name, format!("config parse failed: {e}")))?; - - for (field, url) in [ - ("backchannel_endpoint", &typed.backchannel_endpoint), - ("token_endpoint", &typed.token_endpoint), - ] { - if url.trim().is_empty() { - return Err(cfg_err(&cfg.name, format!("{field} must be non-empty"))); - } - if let Err(e) = require_https(url, typed.insecure_http) { - return Err(cfg_err(&cfg.name, format!("{field} {e}"))); - } - } - if typed.client_id.trim().is_empty() { - return Err(cfg_err( - &cfg.name, - "client_id must be non-empty".to_string(), - )); - } - - let secret = typed - .client_secret_source - .resolve() - .map_err(|e| cfg_err(&cfg.name, format!("client secret resolve failed: {e}")))?; - - let http = reqwest::Client::builder() - .timeout(typed.http_timeout()) - .build() - .map_err(|e| cfg_err(&cfg.name, format!("HTTP client build failed: {e}")))?; - - Ok(Self { - cfg, - typed, - client_secret: Zeroizing::new(secret), - http, - store: Arc::new(InMemoryCorrelationStore::new()), - }) - } - - async fn do_dispatch(&self, payload: &ElicitationPayload) -> PluginResult { - let login_hint = payload.from(); - if login_hint.is_empty() { - return deny( - "elicitation.bad_request", - "CIBA dispatch requires a resolved approver (login_hint) — `from` \ - resolved to empty", - ); - } - - // `requested_expiry` from the step timeout (e.g. "24h"), else the - // configured default. CIBA wants seconds. - let requested_expiry = payload - .timeout() - .and_then(parse_duration_secs) - .unwrap_or(self.typed.default_requested_expiry_seconds); - let requested_expiry = requested_expiry.to_string(); - - // Keycloak constrains `binding_message`: ≤50 chars, no spaces, - // basic plain-text only (verified against Keycloak 26 — a raw - // purpose with spaces/`$`/punctuation is rejected - // `invalid_binding_message`). CIBA's binding_message is an - // anti-phishing correlation code shown on both devices, not the - // full transaction text — the canonical, human-readable `purpose` - // is recorded upstream (apl-core audit). So derive a valid code - // from it. - let binding_message = payload.purpose().map(sanitize_binding_message); - - let mut form: Vec<(&str, &str)> = vec![ - ("scope", &self.typed.scope), - ("login_hint", login_hint), - ("requested_expiry", &requested_expiry), - ]; - if let Some(bm) = &binding_message { - if !bm.is_empty() { - form.push(("binding_message", bm)); - } - } - - let response = match self - .http - .post(&self.typed.backchannel_endpoint) - .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) - .form(&form) - .send() - .await - { - Ok(r) => r, - Err(e) if e.is_timeout() => { - return deny( - "elicitation.op_timeout", - format!("CIBA backchannel POST timed out: {e}"), - ); - }, - Err(e) => { - return deny( - "elicitation.op_unreachable", - format!( - "CIBA backchannel POST to {} failed: {e}", - self.typed.backchannel_endpoint - ), - ); - }, - }; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return deny( - "elicitation.op_rejected", - format!("CIBA backchannel rejected ({status}): {body}"), - ); - } - - let parsed = match response.json::().await { - Ok(p) => p, - Err(e) => { - return deny( - "elicitation.bad_response", - format!("CIBA backchannel response wasn't valid JSON: {e}"), - ); - }, - }; - - // The `auth_req_id` IS the elicitation id the agent echoes on - // retry — opaque and unique, no separate id to generate. - let id = parsed.auth_req_id; - self.store.put( - &id, - Correlation { - expected_approver: login_hint.to_string(), - resolved_approver: None, - }, - ); - - let expires_at = parsed - .expires_in - .map(|secs| (Utc::now() + chrono::Duration::seconds(secs)).to_rfc3339()); - - let mut out = payload.clone(); - out.id = Some(id); - out.status = Some(ElicitationStatusKind::Pending); - out.approver = Some(login_hint.to_string()); - out.expires_at = expires_at; - PluginResult::modify_payload(out) - } - - async fn do_check(&self, payload: &ElicitationPayload) -> PluginResult { - let id = match payload.elicitation_id() { - Some(id) => id, - None => { - return deny( - "elicitation.bad_request", - "CIBA check requires an elicitation id", - ) - }, - }; - - // Cache short-circuit. The OP's `auth_req_id` is single-use: once a - // poll succeeds we exchange it for tokens (consuming it) and cache - // just the approver. A later check — e.g. the confirm-then-apply - // retry after a `peek` already resolved approval — must NOT re-poll - // (the spent id would come back `invalid_grant`). Replay the cached - // approved result instead; `validate` re-compares the approver. - if let Some(corr) = self.store.get(id) { - if corr.resolved_approver.is_some() { - let mut out = payload.clone(); - out.status = Some(ElicitationStatusKind::Resolved); - out.outcome = Some(ElicitationOutcomeKind::Approved); - return PluginResult::modify_payload(out); - } - } - - let form: Vec<(&str, &str)> = vec![("grant_type", GRANT_TYPE_CIBA), ("auth_req_id", id)]; - - let response = match self - .http - .post(&self.typed.token_endpoint) - .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) - .form(&form) - .send() - .await - { - Ok(r) => r, - Err(e) if e.is_timeout() => { - return deny( - "elicitation.op_timeout", - format!("CIBA token poll timed out: {e}"), - ); - }, - Err(e) => { - return deny( - "elicitation.op_unreachable", - format!( - "CIBA token poll to {} failed: {e}", - self.typed.token_endpoint - ), - ); - }, - }; - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - - if status.is_success() { - // Approved — the OP handed back tokens (once). Extract the - // approver claim NOW and store just that string; we never keep - // the token at rest (see store.rs). `validate` then compares - // the stored expected vs resolved approver. - let parsed: TokenResponse = match serde_json::from_str(&body) { - Ok(p) => p, - Err(e) => { - return deny( - "elicitation.bad_response", - format!("CIBA token response wasn't valid JSON: {e}"), - ); - }, - }; - // Prefer the id_token (carries user claims); fall back to the - // access_token. Extract the approver claim and drop the token. - if let Some(token) = parsed.id_token.or(parsed.access_token) { - if let Some(approver) = decode_jwt_claim(&token, &self.typed.approver_claim) { - self.store.set_resolved_approver(id, approver); - } - // token dropped here — never persisted. - } - let mut out = payload.clone(); - out.status = Some(ElicitationStatusKind::Resolved); - out.outcome = Some(ElicitationOutcomeKind::Approved); - return PluginResult::modify_payload(out); - } - - // Non-2xx: a standard OAuth error body drives the lifecycle. - let err_code = serde_json::from_str::(&body) - .map(|e| e.error) - .unwrap_or_default(); - - let (status_kind, outcome) = match err_code.as_str() { - // Still waiting — both mean "keep polling". - "authorization_pending" | "slow_down" => (ElicitationStatusKind::Pending, None), - "expired_token" => (ElicitationStatusKind::Expired, None), - "access_denied" => ( - ElicitationStatusKind::Resolved, - Some(ElicitationOutcomeKind::Denied), - ), - // Anything else is a genuine failure, not a lifecycle state. - _ => { - return deny( - "elicitation.op_rejected", - format!("CIBA token poll failed ({status}): {body}"), - ); - }, - }; - - let mut out = payload.clone(); - out.status = Some(status_kind); - out.outcome = outcome; - PluginResult::modify_payload(out) - } - - async fn do_validate(&self, payload: &ElicitationPayload) -> PluginResult { - let id = match payload.elicitation_id() { - Some(id) => id, - None => { - return deny( - "elicitation.bad_request", - "CIBA validate requires an elicitation id", - ) - }, - }; - - let correlation = match self.store.get(id) { - Some(c) => c, - None => return invalid(payload, "unknown elicitation id"), - }; - // Both sides of this comparison are values already stored from the - // OP's authenticated TLS response, not a token read at rest — this - // check only asks whether the approver who resolved matches the - // approver who was asked. - let resolved = match &correlation.resolved_approver { - Some(a) => a, - None => { - return invalid( - payload, - &format!( - "elicitation has no resolved approver (no `{}` claim was \ - extracted at check, or it hasn't resolved)", - self.typed.approver_claim - ), - ) - }, - }; - - let mut out = payload.clone(); - out.approver = Some(resolved.clone()); - if *resolved == correlation.expected_approver { - out.valid = Some(true); - } else { - out.valid = Some(false); - out.reason = Some(format!( - "approver mismatch: token names `{resolved}`, expected `{}`", - correlation.expected_approver - )); - } - PluginResult::modify_payload(out) - } -} - -#[async_trait] -impl Plugin for CibaApprover { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for CibaApprover { - async fn handle( - &self, - payload: &ElicitationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - match payload.operation() { - ElicitationOp::Dispatch => self.do_dispatch(payload).await, - ElicitationOp::Check => self.do_check(payload).await, - ElicitationOp::Validate => self.do_validate(payload).await, - } - } -} - -/// CIBA backchannel auth response (OIDC CIBA core §7.3). -#[derive(Debug, Deserialize)] -struct BackchannelResponse { - auth_req_id: String, - #[serde(default)] - expires_in: Option, -} - -/// Token-endpoint success body (the slice we need). -#[derive(Debug, Deserialize)] -struct TokenResponse { - #[serde(default)] - access_token: Option, - #[serde(default)] - id_token: Option, -} - -/// Standard OAuth error body — `error` is the machine code -/// (`authorization_pending`, `access_denied`, …). -#[derive(Debug, Deserialize, Default)] -struct OAuthError { - #[serde(default)] - error: String, -} - -fn cfg_err(plugin: &str, msg: String) -> Box { - Box::new(PluginError::Config { - message: format!("plugin '{plugin}' (cpex-plugin-elicitation-ciba): {msg}"), - }) -} - -fn deny(code: &str, reason: impl Into) -> PluginResult { - PluginResult::deny(PluginViolation::new(code, reason.into())) -} - -/// `validate` failure that is a *verdict*, not a transport error: the -/// payload comes back with `valid = false` and a reason, and the bridge -/// reads that — the runtime then denies. (A `deny` here would instead be -/// an `on_error` failure path.) -fn invalid(payload: &ElicitationPayload, reason: &str) -> PluginResult { - let mut out = payload.clone(); - out.valid = Some(false); - out.reason = Some(reason.to_string()); - PluginResult::modify_payload(out) -} - -/// Derive a Keycloak-valid CIBA `binding_message` *cue* from a free-text -/// purpose. Keycloak requires ≤50 chars, no spaces, basic plain-text. -/// -/// `binding_message` is NOT the approver-facing description — by CIBA -/// design it is a short anti-phishing **correlation cue** shown on both -/// the requester's and the approver's devices so the human can confirm -/// they're approving the same transaction. The full, canonical -/// human-readable context is the step's `purpose` (kept verbatim in the -/// apl-core audit record) and is delivered to the approver's device by -/// the Authentication Channel + intent registry. -/// -/// To keep the cue readable rather than mangled, **whitespace runs become -/// a single `-`** and every *other* disallowed character (`$`, `,`, `'`, -/// non-ASCII) is **dropped**, not dashed. So "Approve Bob's $25,000 raise -/// for Jane Smith" → "Approve-Bobs-25000-raise-for-Jane-Smith". Capped at -/// 50, trailing `-` trimmed. -fn sanitize_binding_message(purpose: &str) -> String { - let mut out = String::new(); - let mut prev_dash = false; - for ch in purpose.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch); - prev_dash = false; - } else if ch.is_whitespace() && !out.is_empty() && !prev_dash { - out.push('-'); - prev_dash = true; - } - // Any other disallowed char is dropped (keeps the cue readable). - } - let capped: String = out.chars().take(50).collect(); - capped.trim_end_matches('-').to_string() -} - -/// Parse a duration string (`"30"`, `"30s"`, `"5m"`, `"24h"`, `"2d"`) -/// into seconds. Bare numbers are seconds. Returns `None` on anything -/// unparseable so the caller can fall back to a default. -fn parse_duration_secs(s: &str) -> Option { - let s = s.trim(); - let last = s.chars().last()?; - let (num, mult) = match last { - 's' => (&s[..s.len() - 1], 1), - 'm' => (&s[..s.len() - 1], 60), - 'h' => (&s[..s.len() - 1], 3600), - 'd' => (&s[..s.len() - 1], 86_400), - c if c.is_ascii_digit() => (s, 1), - _ => return None, - }; - num.trim().parse::().ok().map(|n| n * mult) -} - -/// Decode a single string claim from a JWT's payload segment. Claims -/// extraction only — does NOT verify the signature (see the validate -/// notes). Returns `None` if the token is malformed or the claim is -/// absent / non-string. -fn decode_jwt_claim(token: &str, claim: &str) -> Option { - let payload_b64 = token.split('.').nth(1)?; - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .ok()?; - let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - json.get(claim)?.as_str().map(str::to_string) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_duration_units() { - assert_eq!(parse_duration_secs("30"), Some(30)); - assert_eq!(parse_duration_secs("30s"), Some(30)); - assert_eq!(parse_duration_secs("5m"), Some(300)); - assert_eq!(parse_duration_secs("24h"), Some(86_400)); - assert_eq!(parse_duration_secs("2d"), Some(172_800)); - assert_eq!(parse_duration_secs("nonsense"), None); - assert_eq!(parse_duration_secs(""), None); - } - - #[test] - fn binding_message_is_keycloak_valid() { - // The real-world purpose that Keycloak rejected raw. - let bm = sanitize_binding_message("Approve Bob's $25,000 raise for Jane Smith"); - assert!(bm.len() <= 50); - assert!(!bm.contains(' ')); - assert!(bm.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')); - // Whitespace → single `-`; `'`, `$`, `,` dropped (not dashed). - assert_eq!(bm, "Approve-Bobs-25000-raise-for-Jane-Smith"); - // Caps at 50 and trims a trailing separator. - let long = sanitize_binding_message(&"x ".repeat(60)); - assert!(long.len() <= 50); - assert!(!long.ends_with('-')); - // Empty / all-punctuation collapses to empty (no binding_message sent). - assert_eq!(sanitize_binding_message(" — "), ""); - } - - #[test] - fn decode_claim_from_jwt() { - // Build a fake JWT: header.payload.sig, payload carries - // preferred_username. Signature is irrelevant to claim extraction. - let payload = serde_json::json!({ "preferred_username": "alice", "sub": "u-1" }); - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).unwrap()); - let token = format!("aaa.{b64}.bbb"); - assert_eq!( - decode_jwt_claim(&token, "preferred_username").as_deref(), - Some("alice") - ); - assert_eq!(decode_jwt_claim(&token, "sub").as_deref(), Some("u-1")); - assert!(decode_jwt_claim(&token, "missing").is_none()); - assert!(decode_jwt_claim("not-a-jwt", "sub").is_none()); - } -} diff --git a/builtins/plugins/elicitation-ciba/src/config.rs b/builtins/plugins/elicitation-ciba/src/config.rs deleted file mode 100644 index 5ec71cd4..00000000 --- a/builtins/plugins/elicitation-ciba/src/config.rs +++ /dev/null @@ -1,176 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/src/config.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Typed configuration for `CibaApprover`. Deserializes from the -// plugin's `PluginConfig.config` field; the approver's constructor reads -// it and builds the runtime state (the shared `reqwest::Client`, the -// loaded client secret). - -use std::path::PathBuf; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -/// What operators write under `plugins[].config:` in unified -/// config YAML for a CIBA elicitation handler. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CibaConfig { - /// OIDC CIBA backchannel authentication endpoint — where the - /// `dispatch` POST lands (e.g. - /// `https://kc/realms/corp/protocol/openid-connect/ext/ciba/auth`). - pub backchannel_endpoint: String, - - /// OP token endpoint — where the `check` poll lands with - /// `grant_type=urn:openid:params:grant-type:ciba` (e.g. - /// `https://kc/realms/corp/protocol/openid-connect/token`). - pub token_endpoint: String, - - /// OAuth `client_id` identifying our gateway to the OP. The gateway - /// is the CIBA *client* that initiates the backchannel request. - pub client_id: String, - - /// Where to load the client secret from. See [`ClientSecretSource`]. - pub client_secret_source: ClientSecretSource, - - /// OAuth scopes to request on the backchannel auth request. CIBA - /// requires at least `openid`. This is the *OAuth* scope (what the - /// minted token may do), distinct from the APL `scope` arg-binding - /// expression — that one is checked by the apl-core runtime. - #[serde(default = "default_scope")] - pub scope: String, - - /// Default `requested_expiry` (seconds) for the elicitation when the - /// step doesn't carry a `timeout`. How long the approval stays - /// pollable. - #[serde(default = "default_requested_expiry_seconds")] - pub default_requested_expiry_seconds: u64, - - /// Per-call HTTP timeout. Each dispatch/check/validate is a short - /// round-trip; 5s keeps the request hot path bounded. - #[serde(default = "default_http_timeout_seconds")] - pub http_timeout_seconds: u64, - - /// Which token claim names the approver, cross-checked at `validate` - /// against the `login_hint`. Keycloak's default username claim is - /// `preferred_username`; deployments keyed on `sub` set that instead. - #[serde(default = "default_approver_claim")] - pub approver_claim: String, - - /// Explicitly allow `http://` for the endpoints. By default the - /// constructor rejects plaintext because the requests carry - /// `client_id:client_secret`. Set `true` ONLY for `http://localhost` - /// development against a docker-compose Keycloak. - #[serde(default)] - pub insecure_http: bool, -} - -/// Where the gateway's OAuth client secret is loaded from. Mirrors -/// `apl-delegator-oauth`'s source enum — env var (production), file -/// (k8s secret volume), or literal (tests/dev only). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum ClientSecretSource { - EnvVar { name: String }, - File { path: PathBuf }, - Literal { secret: String }, -} - -fn default_scope() -> String { - "openid".to_string() -} - -fn default_requested_expiry_seconds() -> u64 { - 300 -} - -fn default_http_timeout_seconds() -> u64 { - 5 -} - -fn default_approver_claim() -> String { - "preferred_username".to_string() -} - -impl CibaConfig { - /// Per-call HTTP timeout as a `Duration`. - pub fn http_timeout(&self) -> Duration { - Duration::from_secs(self.http_timeout_seconds) - } -} - -impl ClientSecretSource { - /// Resolve the secret at construction time. Errors as a string the - /// caller wraps in `PluginError::Config`. - pub fn resolve(&self) -> Result { - match self { - Self::EnvVar { name } => { - std::env::var(name).map_err(|e| format!("env var '{name}' unavailable: {e}")) - }, - Self::File { path } => std::fs::read_to_string(path) - .map(|s| s.trim().to_string()) - .map_err(|e| format!("secret file '{}' unreadable: {e}", path.display())), - Self::Literal { secret } => Ok(secret.clone()), - } - } -} - -/// Reject `http://` for endpoints that carry credentials. `https://` is -/// always allowed; `http://` only when `insecure_http` is set. Other -/// schemes defer to the upstream URL parser. Returns a short fragment -/// the caller prefixes with field + plugin name. -pub(crate) fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { - let lowered = url.trim_start().to_ascii_lowercase(); - if lowered.starts_with("https://") { - return Ok(()); - } - if lowered.starts_with("http://") { - if insecure_http { - return Ok(()); - } - return Err(format!( - "must use https:// (got '{url}'). Set `insecure_http: true` to allow \ - plaintext for localhost/dev only — never production." - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn config_deserializes_with_defaults() { - let raw = json!({ - "backchannel_endpoint": "https://kc/realms/corp/protocol/openid-connect/ext/ciba/auth", - "token_endpoint": "https://kc/realms/corp/protocol/openid-connect/token", - "client_id": "cpex-gateway", - "client_secret_source": { "kind": "literal", "secret": "dev-only" }, - }); - let cfg: CibaConfig = serde_json::from_value(raw).unwrap(); - assert_eq!(cfg.client_id, "cpex-gateway"); - assert_eq!(cfg.scope, "openid"); - assert_eq!(cfg.default_requested_expiry_seconds, 300); - assert_eq!(cfg.http_timeout_seconds, 5); - assert_eq!(cfg.approver_claim, "preferred_username"); - assert!(!cfg.insecure_http); - } - - #[test] - fn literal_secret_resolves() { - let src = ClientSecretSource::Literal { - secret: "hush".into(), - }; - assert_eq!(src.resolve().unwrap(), "hush"); - } - - #[test] - fn https_gate() { - assert!(require_https("https://kc/", false).is_ok()); - assert!(require_https("http://localhost:8080/", false).is_err()); - assert!(require_https("http://localhost:8080/", true).is_ok()); - } -} diff --git a/builtins/plugins/elicitation-ciba/src/factory.rs b/builtins/plugins/elicitation-ciba/src/factory.rs deleted file mode 100644 index ea799c13..00000000 --- a/builtins/plugins/elicitation-ciba/src/factory.rs +++ /dev/null @@ -1,59 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/src/factory.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `PluginFactory` impl for the CIBA elicitation handler. Lives here -// (alongside the approver) so every host wires it up the same way. -// -// Operators declare it in CPEX YAML as: -// -// plugins: -// - name: manager-approver -// kind: elicitation/ciba -// hooks: [elicit] -// config: -// backchannel_endpoint: https://kc/realms/corp/protocol/openid-connect/ext/ciba/auth -// token_endpoint: https://kc/realms/corp/protocol/openid-connect/token -// client_id: cpex-gateway -// client_secret_source: { kind: env, name: CIBA_CLIENT_SECRET } -// -// Then policy routes name it: `require_approval(manager-approver, from: claim.manager, ...)`. -// -// Hosts call -// `mgr.register_factory("elicitation/ciba", Box::new(CibaApproverFactory))` -// before loading config. - -use std::sync::Arc; - -use cpex_core::{ - elicitation::{ElicitationHook, HOOK_ELICIT}, - error::PluginError, - factory::{PluginFactory, PluginInstance}, - hooks::TypedHandlerAdapter, - plugin::PluginConfig, -}; - -use crate::CibaApprover; - -/// The plugin `kind:` string operators write in CPEX YAML to declare a -/// CIBA elicitation handler. -pub const KIND: &str = "elicitation/ciba"; - -/// Factory for `kind: elicitation/ciba` plugins. Instantiates a -/// `CibaApprover` from the `config:` block and registers it on the -/// `elicit` hook. -pub struct CibaApproverFactory; - -impl PluginFactory for CibaApproverFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let approver = Arc::new(CibaApprover::new(config.clone())?); - let handler = Arc::new(TypedHandlerAdapter::::new(Arc::clone( - &approver, - ))); - Ok(PluginInstance { - plugin: approver, - handlers: vec![(HOOK_ELICIT, handler)], - }) - } -} diff --git a/builtins/plugins/elicitation-ciba/src/lib.rs b/builtins/plugins/elicitation-ciba/src/lib.rs deleted file mode 100644 index ffb4023a..00000000 --- a/builtins/plugins/elicitation-ciba/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-plugin-elicitation-ciba — `ElicitationHandler` backed by OIDC CIBA -// (Client-Initiated Backchannel Authentication). -// -// The host registers this handler against the `elicit` hook; APL -// policies select it by name (`require_approval(manager-approver, ...)`). -// The apl-cpex bridge invokes it once per dispatch / check / validate -// across the elicitation's lifetime; this crate turns each into the -// corresponding CIBA round-trip against the configured OP (Keycloak by -// default). -// -// See the module docs for the per-operation flow: -// * [`config`] — typed `config:` block. -// * [`store`] — in-flight correlation store (in-memory v1). -// * [`approver`] — the handler + CIBA HTTP. -// * [`factory`] — `kind: elicitation/ciba` registration. - -pub mod approver; -pub mod config; -pub mod factory; -pub mod store; - -pub use approver::CibaApprover; -pub use config::{CibaConfig, ClientSecretSource}; -pub use factory::{CibaApproverFactory, KIND}; -pub use store::{Correlation, CorrelationStore, InMemoryCorrelationStore}; diff --git a/builtins/plugins/elicitation-ciba/src/store.rs b/builtins/plugins/elicitation-ciba/src/store.rs deleted file mode 100644 index 12dff4fb..00000000 --- a/builtins/plugins/elicitation-ciba/src/store.rs +++ /dev/null @@ -1,138 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/src/store.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Correlation store — maps an elicitation id (the CIBA `auth_req_id`, -// which the agent echoes on retry) to the state the handler needs across -// the dispatch → check → validate lifetime: who the *expected* approver is -// (`login_hint`, set at dispatch) and, once `check` sees a successful poll, -// who *actually* approved (the approver claim extracted from the OP token). -// -// # Why we store the extracted claim, not the token -// -// CIBA hands the token back exactly once (a second poll on the same -// `auth_req_id` fails), and `validate` runs on a later request than -// `check` — so the relevant fact must be carried across. We extract the -// approver claim at `check` and store *that string*, then drop the token. -// `validate` compares the two stored strings (expected vs resolved); it -// never needs the token. This keeps a **bearer credential out of the -// store at rest** — so even a leaked/co-tenant store reveals only "who -// approved what," never a usable token. (The `require_step_up` path, -// which forwards the CIBA token, is separate and does not use this store.) -// -// v1 is in-process (`InMemoryCorrelationStore`). That survives retries -// within one gateway process — enough for a single-node demo. The trait -// is the seam for a Valkey-backed store (cross-node / cross-restart) — -// deferred; when added, the CIBA store should use its own instance or an -// ACL-scoped user so it is isolated from the session-store keyspace. - -use std::collections::HashMap; -use std::sync::Mutex; - -/// State tracked per in-flight elicitation. -#[derive(Debug, Clone)] -pub struct Correlation { - /// The approver the backchannel request named (`login_hint`), set at - /// dispatch. `validate` cross-checks the resolved approver against it. - pub expected_approver: String, - /// Who actually approved — the approver claim (e.g. `preferred_username`) - /// extracted from the OP token at `check`. `None` until a successful - /// poll resolves it. We keep the **extracted claim, not the token**, so - /// no bearer credential sits in the store at rest. - pub resolved_approver: Option, -} - -/// Storage for in-flight CIBA correlations, keyed by elicitation id. -pub trait CorrelationStore: Send + Sync { - /// Record a freshly dispatched elicitation. - fn put(&self, id: &str, correlation: Correlation); - /// Read the current state for an id, if present. - fn get(&self, id: &str) -> Option; - /// Record who approved (the extracted claim) against an existing - /// correlation. No-op if the id is unknown. - fn set_resolved_approver(&self, id: &str, approver: String); -} - -/// In-process correlation store. Thread-safe; the plugin instance is -/// shared across requests, so this map persists across an agent's retries -/// within one gateway process. -#[derive(Debug, Default)] -pub struct InMemoryCorrelationStore { - inner: Mutex>, -} - -impl InMemoryCorrelationStore { - pub fn new() -> Self { - Self::default() - } -} - -impl CorrelationStore for InMemoryCorrelationStore { - fn put(&self, id: &str, correlation: Correlation) { - self.inner - .lock() - .unwrap_or_else(|p| p.into_inner()) - .insert(id.to_string(), correlation); - } - - fn get(&self, id: &str) -> Option { - self.inner - .lock() - .unwrap_or_else(|p| p.into_inner()) - .get(id) - .cloned() - } - - fn set_resolved_approver(&self, id: &str, approver: String) { - if let Some(c) = self - .inner - .lock() - .unwrap_or_else(|p| p.into_inner()) - .get_mut(id) - { - c.resolved_approver = Some(approver); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn put_get_roundtrip() { - let store = InMemoryCorrelationStore::new(); - store.put( - "req-1", - Correlation { - expected_approver: "alice".into(), - resolved_approver: None, - }, - ); - let c = store.get("req-1").expect("present"); - assert_eq!(c.expected_approver, "alice"); - assert!(c.resolved_approver.is_none()); - assert!(store.get("missing").is_none()); - } - - #[test] - fn set_resolved_approver_records_on_existing() { - let store = InMemoryCorrelationStore::new(); - store.put( - "req-1", - Correlation { - expected_approver: "alice".into(), - resolved_approver: None, - }, - ); - store.set_resolved_approver("req-1", "alice".into()); - assert_eq!( - store.get("req-1").unwrap().resolved_approver.as_deref(), - Some("alice") - ); - // Unknown id is a silent no-op. - store.set_resolved_approver("missing", "x".into()); - assert!(store.get("missing").is_none()); - } -} diff --git a/builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs b/builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs deleted file mode 100644 index 49828a7f..00000000 --- a/builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs +++ /dev/null @@ -1,281 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Integration tests for the CIBA elicitation handler against a mock OP -// (mockito). Exercises the real request shapes and the lifecycle mapping -// for dispatch → check → validate without a live Keycloak. - -use std::collections::HashSet; - -use base64::Engine; -use serde_json::json; - -use cpex_core::context::PluginContext; -use cpex_core::elicitation::{ - ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, ElicitationStatusKind, -}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::HookHandler; -use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; - -use cpex_plugin_elicitation_ciba::CibaApprover; - -// --------------------------------------------------------------------- -// Harness -// --------------------------------------------------------------------- - -fn approver(server_url: &str) -> CibaApprover { - let cfg = PluginConfig { - name: "manager-approver".to_string(), - kind: "elicitation/ciba".to_string(), - description: None, - author: None, - version: None, - hooks: vec!["elicit".to_string()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: HashSet::new(), - tags: Vec::new(), - conditions: Vec::new(), - config: Some(json!({ - "backchannel_endpoint": format!("{server_url}/ciba/auth"), - "token_endpoint": format!("{server_url}/token"), - "client_id": "cpex-gateway", - "client_secret_source": { "kind": "literal", "secret": "shh" }, - // mockito serves http:// — allow it for the test only. - "insecure_http": true, - })), - }; - CibaApprover::new(cfg).expect("construct approver") -} - -async fn run(approver: &CibaApprover, payload: ElicitationPayload) -> ElicitationPayload { - let ext = Extensions::default(); - let mut ctx = PluginContext::new(); - let result = approver.handle(&payload, &ext, &mut ctx).await; - assert!( - result.continue_processing, - "handler denied: {:?}", - result.violation - ); - result - .modified_payload - .expect("handler returned an ElicitationPayload") -} - -/// Build a fake id_token whose payload carries `preferred_username`. -fn fake_id_token(username: &str) -> String { - let payload = json!({ "preferred_username": username, "sub": "u-1" }); - let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).unwrap()); - format!("aaa.{b64}.sig") -} - -// --------------------------------------------------------------------- -// Scenarios -// --------------------------------------------------------------------- - -#[tokio::test] -async fn dispatch_posts_backchannel_and_returns_auth_req_id() { - let mut server = mockito::Server::new_async().await; - let m = server - .mock("POST", "/ciba/auth") - // Assert the CIBA request shape: login_hint + binding_message. - // The purpose "Approve raise" is sanitized to a Keycloak-valid, - // space-free correlation code before it goes on the wire. - .match_body(mockito::Matcher::AllOf(vec![ - mockito::Matcher::UrlEncoded("login_hint".into(), "alice@corp.com".into()), - mockito::Matcher::UrlEncoded("binding_message".into(), "Approve-raise".into()), - mockito::Matcher::UrlEncoded("scope".into(), "openid".into()), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(json!({ "auth_req_id": "REQ-123", "expires_in": 300, "interval": 5 }).to_string()) - .create_async() - .await; - - let app = approver(&server.url()); - let payload = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@corp.com") - .with_purpose("Approve raise"); - let out = run(&app, payload).await; - - m.assert_async().await; - assert_eq!(out.id.as_deref(), Some("REQ-123")); - assert_eq!(out.status, Some(ElicitationStatusKind::Pending)); - assert_eq!(out.approver.as_deref(), Some("alice@corp.com")); - assert!(out.expires_at.is_some()); -} - -#[tokio::test] -async fn check_authorization_pending_maps_to_pending() { - let mut server = mockito::Server::new_async().await; - let m = server - .mock("POST", "/token") - .match_body(mockito::Matcher::UrlEncoded( - "auth_req_id".into(), - "REQ-123".into(), - )) - .with_status(400) - .with_header("content-type", "application/json") - .with_body(json!({ "error": "authorization_pending" }).to_string()) - .create_async() - .await; - - let app = approver(&server.url()); - let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") - .with_elicitation_id("REQ-123"); - let out = run(&app, payload).await; - - m.assert_async().await; - assert_eq!(out.status, Some(ElicitationStatusKind::Pending)); - assert!(out.outcome.is_none()); -} - -#[tokio::test] -async fn check_success_maps_to_resolved_approved() { - let mut server = mockito::Server::new_async().await; - let _m = server - .mock("POST", "/token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - json!({ "access_token": "at", "id_token": fake_id_token("alice@corp.com") }) - .to_string(), - ) - .create_async() - .await; - - let app = approver(&server.url()); - let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") - .with_elicitation_id("REQ-123"); - let out = run(&app, payload).await; - - assert_eq!(out.status, Some(ElicitationStatusKind::Resolved)); - assert_eq!(out.outcome, Some(ElicitationOutcomeKind::Approved)); -} - -#[tokio::test] -async fn check_access_denied_maps_to_resolved_denied() { - let mut server = mockito::Server::new_async().await; - let _m = server - .mock("POST", "/token") - .with_status(400) - .with_body(json!({ "error": "access_denied" }).to_string()) - .create_async() - .await; - - let app = approver(&server.url()); - let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") - .with_elicitation_id("REQ-123"); - let out = run(&app, payload).await; - - assert_eq!(out.status, Some(ElicitationStatusKind::Resolved)); - assert_eq!(out.outcome, Some(ElicitationOutcomeKind::Denied)); -} - -#[tokio::test] -async fn check_expired_token_maps_to_expired() { - let mut server = mockito::Server::new_async().await; - let _m = server - .mock("POST", "/token") - .with_status(400) - .with_body(json!({ "error": "expired_token" }).to_string()) - .create_async() - .await; - - let app = approver(&server.url()); - let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") - .with_elicitation_id("REQ-123"); - let out = run(&app, payload).await; - - assert_eq!(out.status, Some(ElicitationStatusKind::Expired)); -} - -#[tokio::test] -async fn full_flow_dispatch_check_validate_approves() { - // One approver instance across all three ops, so the in-memory - // correlation store carries the expected approver + cached token. - let mut server = mockito::Server::new_async().await; - let _auth = server - .mock("POST", "/ciba/auth") - .with_status(200) - .with_body(json!({ "auth_req_id": "REQ-9", "expires_in": 300 }).to_string()) - .create_async() - .await; - let _tok = server - .mock("POST", "/token") - .with_status(200) - .with_body(json!({ "id_token": fake_id_token("alice@corp.com") }).to_string()) - .create_async() - .await; - - let app = approver(&server.url()); - - // 1. dispatch — login_hint = the resolved approver. - let d = run( - &app, - ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@corp.com") - .with_purpose("Approve raise"), - ) - .await; - let id = d.id.clone().expect("dispatch id"); - - // 2. check — approved. - let c = run( - &app, - ElicitationPayload::new(ElicitationOp::Check, "approval", "").with_elicitation_id(&id), - ) - .await; - assert_eq!(c.outcome, Some(ElicitationOutcomeKind::Approved)); - - // 3. validate — token's preferred_username matches the login_hint. - let v = run( - &app, - ElicitationPayload::new(ElicitationOp::Validate, "approval", "").with_elicitation_id(&id), - ) - .await; - assert_eq!(v.valid, Some(true)); - assert_eq!(v.approver.as_deref(), Some("alice@corp.com")); -} - -#[tokio::test] -async fn validate_rejects_approver_mismatch() { - let mut server = mockito::Server::new_async().await; - let _auth = server - .mock("POST", "/ciba/auth") - .with_status(200) - .with_body(json!({ "auth_req_id": "REQ-x", "expires_in": 300 }).to_string()) - .create_async() - .await; - // The token comes back naming a DIFFERENT user than the login_hint. - let _tok = server - .mock("POST", "/token") - .with_status(200) - .with_body(json!({ "id_token": fake_id_token("mallory@corp.com") }).to_string()) - .create_async() - .await; - - let app = approver(&server.url()); - let d = run( - &app, - ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@corp.com"), - ) - .await; - let id = d.id.unwrap(); - let _ = run( - &app, - ElicitationPayload::new(ElicitationOp::Check, "approval", "").with_elicitation_id(&id), - ) - .await; - let v = run( - &app, - ElicitationPayload::new(ElicitationOp::Validate, "approval", "").with_elicitation_id(&id), - ) - .await; - - assert_eq!(v.valid, Some(false)); - assert!(v.reason.unwrap().contains("approver mismatch")); -} diff --git a/builtins/plugins/elicitation-ciba/tests/live_keycloak.rs b/builtins/plugins/elicitation-ciba/tests/live_keycloak.rs deleted file mode 100644 index fbd31c0e..00000000 --- a/builtins/plugins/elicitation-ciba/tests/live_keycloak.rs +++ /dev/null @@ -1,114 +0,0 @@ -// Location: ./builtins/plugins/elicitation-ciba/tests/live_keycloak.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Phase 0 verification — runs the *automatable* half of the CIBA flow -// (dispatch → check-pending) against a REAL Keycloak, so "Phase 0 -// verified" is a repeatable test, not just the manual runbook. -// -// The approval step is decoupled and human-driven, -// so it can't be asserted here — but dispatch + the first poll exercise -// the realm/client/auth config end-to-end, which is what usually breaks. -// -// `#[ignore]` by default. To run against a Keycloak configured per the -// runbook: -// -// CIBA_BACKCHANNEL_ENDPOINT=http://localhost:8080/realms/corp/protocol/openid-connect/ext/ciba/auth \ -// CIBA_TOKEN_ENDPOINT=http://localhost:8080/realms/corp/protocol/openid-connect/token \ -// CIBA_CLIENT_ID=cpex-gateway \ -// CIBA_CLIENT_SECRET= \ -// CIBA_LOGIN_HINT=alice \ -// cargo test -p cpex-plugin-elicitation-ciba --test live_keycloak -- --ignored --nocapture - -use std::collections::HashSet; - -use serde_json::json; - -use cpex_core::context::PluginContext; -use cpex_core::elicitation::{ElicitationOp, ElicitationPayload, ElicitationStatusKind}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::HookHandler; -use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; - -use cpex_plugin_elicitation_ciba::CibaApprover; - -/// Read a required env var, or `None` (so the test skips cleanly). -fn env(name: &str) -> Option { - std::env::var(name).ok().filter(|v| !v.is_empty()) -} - -#[tokio::test] -#[ignore = "requires a live Keycloak"] -async fn live_dispatch_then_pending() { - let (Some(backchannel), Some(token), Some(client_id), Some(secret), Some(login_hint)) = ( - env("CIBA_BACKCHANNEL_ENDPOINT"), - env("CIBA_TOKEN_ENDPOINT"), - env("CIBA_CLIENT_ID"), - env("CIBA_CLIENT_SECRET"), - env("CIBA_LOGIN_HINT"), - ) else { - eprintln!("SKIP: set CIBA_BACKCHANNEL_ENDPOINT / _TOKEN_ENDPOINT / _CLIENT_ID / _CLIENT_SECRET / _LOGIN_HINT"); - return; - }; - - let insecure = backchannel.starts_with("http://") || token.starts_with("http://"); - let cfg = PluginConfig { - name: "manager-approver".to_string(), - kind: "elicitation/ciba".to_string(), - description: None, - author: None, - version: None, - hooks: vec!["elicit".to_string()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: HashSet::new(), - tags: Vec::new(), - conditions: Vec::new(), - config: Some(json!({ - "backchannel_endpoint": backchannel, - "token_endpoint": token, - "client_id": client_id, - "client_secret_source": { "kind": "literal", "secret": secret }, - "insecure_http": insecure, - })), - }; - let approver = CibaApprover::new(cfg).expect("construct approver"); - let ext = Extensions::default(); - - // 1. dispatch → backchannel auth request. - let dispatch = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", &login_hint) - .with_purpose("Phase 0 verification — please ignore"); - let mut ctx = PluginContext::new(); - let out = approver.handle(&dispatch, &ext, &mut ctx).await; - assert!( - out.continue_processing, - "dispatch denied: {:?}", - out.violation - ); - let dispatched = out.modified_payload.expect("dispatch payload"); - let id = dispatched - .id - .clone() - .expect("Keycloak returned an auth_req_id"); - assert_eq!(dispatched.status, Some(ElicitationStatusKind::Pending)); - assert_eq!(dispatched.approver.as_deref(), Some(login_hint.as_str())); - eprintln!("dispatch OK — auth_req_id = {id}"); - - // 2. check → token poll before approval. Without a - // completed decoupled approval this must report Pending. - let check = - ElicitationPayload::new(ElicitationOp::Check, "approval", "").with_elicitation_id(&id); - let mut ctx = PluginContext::new(); - let out = approver.handle(&check, &ext, &mut ctx).await; - assert!(out.continue_processing, "check denied: {:?}", out.violation); - let checked = out.modified_payload.expect("check payload"); - assert_eq!( - checked.status, - Some(ElicitationStatusKind::Pending), - "expected authorization_pending before approval; got {:?}", - checked.status - ); - eprintln!("check OK — status = Pending (no approval yet, as expected)"); -} diff --git a/builtins/plugins/identity-jwt/Cargo.toml b/builtins/plugins/identity-jwt/Cargo.toml deleted file mode 100644 index 02ca260a..00000000 --- a/builtins/plugins/identity-jwt/Cargo.toml +++ /dev/null @@ -1,92 +0,0 @@ -# Location: ./builtins/plugins/identity-jwt/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-plugin-identity-jwt — JWT-based `IdentityResolveHandler`. -# -# Validates inbound JWTs against configured trusted issuers -# (signature + exp + aud + iss claims) and maps the validated claims -# into `SubjectExtension` / `ClientExtension` via a configurable -# claim-mapper. The raw token is stashed in -# `RawCredentialsExtension.inbound_tokens` for forwarding plugins -# downstream. -# -# Lightweight (~5-15 transitive deps), so it stays in the workspace's -# default-members. - -[package] -name = "cpex-plugin-identity-jwt" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX identity handler — JWT validation and claim mapping." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -cpex-core = { workspace = true } - -# `jsonwebtoken` is the de facto JWT library for Rust. Supports -# RS256/RS384/RS512, ES256/ES384, EdDSA, HS256/HS384/HS512. Default -# features include `use_pem` (load DecodingKey from PEM strings) which -# we want. v10 split out the crypto backend into a required feature -# (neither `rust_crypto` nor `aws_lc_rs` is on by default) and panics -# at first sign/verify if none is selected. We pick `aws_lc_rs`: the -# alternative, `rust_crypto`, pulls the pure-Rust `rsa` crate as a -# runtime dependency, and that crate carries RUSTSEC-2023-0071 (Marvin -# timing sidechannel) with no fixed release — which then propagates the -# advisory to every downstream consumer of this plugin. aws-lc-rs keeps -# `rsa` out of the runtime tree at the cost of a C/cmake build step. -jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } - -# `base64` for the peek-at-iss helper (split + URL_SAFE_NO_PAD decode -# of the middle JWT segment). Pinned to 0.22 to match what -# jsonwebtoken 9 pulls — Cargo dedups to a single version. -base64 = "0.22" - -# `chrono` for the `resolved_at` timestamp on IdentityPayload. Comes -# in transitively via cpex-core already; redeclaring keeps the -# direct-dep relationship visible. -chrono = { workspace = true } - -async-trait = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } - -# Async HTTP — used by `DecodingKeySource::build_async()` to fetch -# IdP JWKS during `Plugin::initialize()`. We default the rustls-tls -# backend (no OpenSSL system dep) and turn off any features we don't -# use to keep the dep tree lean. cpex-plugin-delegator-oauth pulls reqwest -# too, so cargo dedups to one copy. -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } - -# `futures::join_all` so multiple resolvers' JWKS endpoints fetch -# concurrently at initialize time rather than one-at-a-time. -futures = { workspace = true } - -# `tokio::spawn` + `tokio::time::interval` for the background JWKS -# refresh tasks introduced in Slice B. The runtime is already in -# the workspace dep tree via cpex-core / apl-cpex, so this just -# makes the existing types directly nameable here. We only need -# the runtime + time features at runtime; full "macros / rt / rt-multi-thread" -# was test-only previously. -tokio = { workspace = true, features = ["rt", "time"] } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -# Mock JWKS endpoint for the async `JwksUrl` resolution tests. -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"] } -# 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/src/claim_map.rs b/builtins/plugins/identity-jwt/src/claim_map.rs deleted file mode 100644 index 28aa8ffa..00000000 --- a/builtins/plugins/identity-jwt/src/claim_map.rs +++ /dev/null @@ -1,408 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/src/claim_map.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `ClaimMapper` — converts validated JWT claims into a populated -// `SubjectExtension`. -// -// Different IdPs use different claim shapes: -// -// * Keycloak — `realm_access.roles` (nested array), `email`, -// `preferred_username`, custom `groups` array -// * Auth0 — flat `permissions` array, `https://my-app/roles` -// (namespaced custom claims), `email` -// * Cognito — `cognito:groups`, `cognito:username`, -// `cognito:roles` -// * Standard OIDC — `sub`, `email`, `name`, `groups`, … -// -// `StandardClaimMap` covers the OIDC-standard shape; deployments -// with bespoke IdPs implement `ClaimMapper` themselves and inject -// at resolver construction. - -use std::collections::HashMap; - -use serde_json::Value; - -use cpex_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; - -/// Convert a validated JWT's claim map into the typed identity slot -/// for the resolver's configured role. -/// -/// Implementations supply one method per role they understand: -/// -/// * [`map_subject`] — `sub` plus subject-shaped fields, for -/// `TokenRole::User`. -/// * [`map_client`] — `client_id` plus client-shaped fields, for -/// `TokenRole::Client`. -/// * [`map_workload`] — SPIFFE-style identity, for `TokenRole::CallerWorkload`. -/// -/// Each defaults to `None` so existing custom mappers stay valid — -/// they get implicit "this mapper doesn't know how to do that role," -/// which the resolver surfaces as `auth.mapping_failed` when an -/// operator wires a role the mapper can't fill. -/// -/// `Debug` is a supertrait so structs holding `Arc` -/// (notably `JwtIdentityResolver`) can themselves derive `Debug`. -/// -/// [`map_subject`]: ClaimMapper::map_subject -/// [`map_client`]: ClaimMapper::map_client -/// [`map_workload`]: ClaimMapper::map_workload -pub trait ClaimMapper: std::fmt::Debug + Send + Sync { - /// Map JWT claims into a `SubjectExtension` (for `role: user`). - fn map_subject(&self, claims: &HashMap) -> Option { - let _ = claims; - None - } - - /// Map JWT claims into a `ClientExtension` (for `role: client`). - /// Default returns `None` — implementations that handle client - /// tokens override this. - fn map_client(&self, claims: &HashMap) -> Option { - let _ = claims; - None - } - - /// Map JWT claims into a `WorkloadIdentity` (for `role: workload`). - /// Default returns `None` — implementations that handle SPIFFE / - /// SPIFFE-JWT-SVID tokens override this. - fn map_workload(&self, claims: &HashMap) -> Option { - let _ = claims; - None - } -} - -/// Type alias matching what `jsonwebtoken::decode::(...)` -/// produces — a JSON object's key/value pairs. -pub type ClaimMap = HashMap; - -/// Default `ClaimMapper` covering the OIDC-standard claim shape: -/// -/// * `sub` → `subject.id` (required) -/// * `roles` → `subject.roles` (string array) -/// * `permissions` / `scope` → `subject.permissions` (array or -/// space-separated string) -/// * `groups` / `teams` → `subject.teams` (string array) -/// * Every other claim → `subject.claims.` (stringified) -/// -/// Implementations with non-standard IdPs (Keycloak's nested -/// `realm_access.roles`, AWS Cognito's `cognito:*` prefixed claims) -/// write their own `ClaimMapper`; this struct is for the common -/// vanilla-OIDC case. -#[derive(Debug, Clone, Default)] -pub struct StandardClaimMap; - -impl ClaimMapper for StandardClaimMap { - fn map_client(&self, claims: &ClaimMap) -> Option { - // `client_id` is required for ClientExtension — it's the anchor - // identifier policy authors gate on. Falls back to `azp` - // (authorized party, OIDC §2 for the "client_id of the party - // to which the token was issued") which Keycloak and several - // OPs send in place of `client_id`. - let client_id = claims - .get("client_id") - .or_else(|| claims.get("azp")) - .and_then(Value::as_str)? - .to_string(); - - let mut client = ClientExtension { - client_id, - ..Default::default() - }; - - if let Some(name) = claims.get("client_name").and_then(Value::as_str) { - client.client_name = Some(name.to_string()); - } - - // Scopes — array OR space-separated string. - if let Some(arr) = claims.get("authorized_scopes").and_then(Value::as_array) { - for v in arr { - if let Some(s) = v.as_str() { - client.authorized_scopes.push(s.to_string()); - } - } - } else if let Some(s) = claims.get("scope").and_then(Value::as_str) { - for scope in s.split_whitespace() { - if !scope.is_empty() { - client.authorized_scopes.push(scope.to_string()); - } - } - } - - // Audiences — single string or array (RFC 7519 §4.1.3). - match claims.get("aud") { - Some(Value::String(s)) => client.authorized_audiences.push(s.clone()), - Some(Value::Array(arr)) => { - for v in arr { - if let Some(s) = v.as_str() { - client.authorized_audiences.push(s.to_string()); - } - } - }, - _ => {}, - } - - // Platform-native roles. - if let Some(arr) = claims.get("roles").and_then(Value::as_array) { - for v in arr { - if let Some(s) = v.as_str() { - client.roles.push(s.to_string()); - } - } - } - - // Remaining claims — keyed by name with full Value preserved - // (ClientExtension.claims is HashMap, - // unlike SubjectExtension.claims which stringifies). - const RESERVED: &[&str] = &[ - "client_id", - "azp", - "client_name", - "authorized_scopes", - "scope", - "aud", - "roles", - "iss", - "exp", - "nbf", - "iat", - "jti", - "sub", - ]; - for (k, v) in claims { - if RESERVED.contains(&k.as_str()) { - continue; - } - client.claims.insert(k.clone(), v.clone()); - } - - Some(client) - } - - fn map_workload(&self, claims: &ClaimMap) -> Option { - // SPIFFE JWT-SVID convention: the SPIFFE ID lives in `sub` - // (per the SPIFFE JWT-SVID spec). We look there first, then - // fall back to an explicit `spiffe_id` claim for IdPs that - // surface it separately. - let spiffe_id = claims - .get("sub") - .and_then(Value::as_str) - .filter(|s| s.starts_with("spiffe://")) - .or_else(|| claims.get("spiffe_id").and_then(Value::as_str)) - // Guard the `spiffe_id` fallback with the SAME prefix check as - // `sub`: a non-SPIFFE `sub` must not smuggle in an arbitrary - // `spiffe_id` claim and be accepted as a workload identity. - .filter(|s| s.starts_with("spiffe://")) - .map(str::to_string)?; - - // Trust domain — pull from the SPIFFE-ID host part. - let trust_domain = spiffe_id - .strip_prefix("spiffe://") - .and_then(|rest| rest.split('/').next()) - .map(str::to_string); - - Some(WorkloadIdentity { - spiffe_id: Some(spiffe_id), - trust_domain, - attested_at: None, - attestor: Some("jwt".to_string()), - ..Default::default() - }) - } - - fn map_subject(&self, claims: &ClaimMap) -> Option { - // `sub` is required — RFC 7519 §4.1.2 makes it optional in - // the spec but it's effectively mandatory for identity flows. - let sub = claims.get("sub").and_then(Value::as_str)?.to_string(); - - let mut subject = SubjectExtension { - id: Some(sub), - ..Default::default() - }; - - // `roles` — array of strings. - if let Some(arr) = claims.get("roles").and_then(Value::as_array) { - for v in arr { - if let Some(s) = v.as_str() { - subject.roles.insert(s.to_string()); - } - } - } - - // `permissions` (array) OR `scope` (space-separated string, - // OAuth-style). Either populates `subject.permissions`. - if let Some(arr) = claims.get("permissions").and_then(Value::as_array) { - for v in arr { - if let Some(s) = v.as_str() { - subject.permissions.insert(s.to_string()); - } - } - } else if let Some(s) = claims.get("scope").and_then(Value::as_str) { - for scope in s.split_whitespace() { - if !scope.is_empty() { - subject.permissions.insert(scope.to_string()); - } - } - } - - // `teams` (explicit) preferred; fall back to `groups` (OIDC - // conventional name for the same concept). - if let Some(arr) = claims.get("teams").and_then(Value::as_array) { - for v in arr { - if let Some(s) = v.as_str() { - subject.teams.insert(s.to_string()); - } - } - } else if let Some(arr) = claims.get("groups").and_then(Value::as_array) { - for v in arr { - if let Some(s) = v.as_str() { - subject.teams.insert(s.to_string()); - } - } - } - - // Every other claim → `subject.claims.`. - // SubjectExtension.claims is HashMap, so - // non-string values get stringified (JSON-serialized). The - // reserved-claim set is the ones we already mapped to - // structured fields, plus the JWT standard registered - // claims (iss/aud/exp/nbf/iat/jti) which aren't useful as - // policy-visible claims. - const RESERVED: &[&str] = &[ - "sub", - "roles", - "permissions", - "scope", - "teams", - "groups", - "iss", - "aud", - "exp", - "nbf", - "iat", - "jti", - ]; - for (k, v) in claims { - if RESERVED.contains(&k.as_str()) { - continue; - } - let stringified = match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - subject.claims.insert(k.clone(), stringified); - } - - Some(subject) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn make_claims(json: Value) -> ClaimMap { - json.as_object().unwrap().clone().into_iter().collect() - } - - #[test] - fn sub_becomes_subject_id() { - let claims = make_claims(json!({"sub": "alice@corp.com"})); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); - } - - #[test] - fn missing_sub_returns_none() { - // No `sub` claim → mapper rejects. Caller will surface - // this as `auth.mapping_failed`. - let claims = make_claims(json!({"email": "alice@corp.com"})); - assert!(StandardClaimMap.map_subject(&claims).is_none()); - } - - #[test] - fn roles_array_becomes_subject_roles() { - let claims = make_claims(json!({ - "sub": "alice", - "roles": ["hr", "admin"], - })); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert!(subject.roles.contains("hr")); - assert!(subject.roles.contains("admin")); - } - - #[test] - fn scope_string_splits_into_permissions() { - // OAuth-style space-separated scope claim — `scope: "read write"`. - let claims = make_claims(json!({ - "sub": "alice", - "scope": "read write delete", - })); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert!(subject.permissions.contains("read")); - assert!(subject.permissions.contains("write")); - assert!(subject.permissions.contains("delete")); - } - - #[test] - fn permissions_array_preferred_over_scope() { - // If both are present, `permissions` (array) wins. Most - // modern IdPs send arrays; OAuth-1-era `scope` is a fallback. - let claims = make_claims(json!({ - "sub": "alice", - "permissions": ["call_tool", "list_tools"], - "scope": "read write", - })); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert!(subject.permissions.contains("call_tool")); - // `scope` ignored when `permissions` is present. - assert!(!subject.permissions.contains("read")); - } - - #[test] - fn groups_fallback_when_teams_absent() { - let claims = make_claims(json!({ - "sub": "alice", - "groups": ["engineering", "platform"], - })); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert!(subject.teams.contains("engineering")); - assert!(subject.teams.contains("platform")); - } - - #[test] - fn teams_preferred_over_groups() { - let claims = make_claims(json!({ - "sub": "alice", - "teams": ["explicit-team"], - "groups": ["fallback-group"], - })); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert!(subject.teams.contains("explicit-team")); - assert!(!subject.teams.contains("fallback-group")); - } - - #[test] - fn unmapped_claims_land_in_subject_claims_map() { - let claims = make_claims(json!({ - "sub": "alice", - "email": "alice@corp.com", - "preferred_username": "alice", - "iat": 1700000000, // reserved, should be skipped - })); - let subject = StandardClaimMap.map_subject(&claims).unwrap(); - assert_eq!( - subject.claims.get("email"), - Some(&"alice@corp.com".to_string()) - ); - assert_eq!( - subject.claims.get("preferred_username"), - Some(&"alice".to_string()), - ); - // Reserved JWT claims aren't propagated as policy-visible - // subject claims. - assert!(!subject.claims.contains_key("iat")); - assert!(!subject.claims.contains_key("sub")); - } -} diff --git a/builtins/plugins/identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs deleted file mode 100644 index dd181f61..00000000 --- a/builtins/plugins/identity-jwt/src/config.rs +++ /dev/null @@ -1,514 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/src/config.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Typed configuration for `JwtIdentityResolver`. Deserializes from -// the plugin's `PluginConfig.config: Option` field; the -// resolver's constructor reads this and builds the runtime state -// (DecodingKey instances, claim mapper selection). -// -// Serializable intermediate representations (`DecodingKeySource`) -// stand in for non-serializable runtime types (`DecodingKey`). The -// build step on each type turns the config representation into the -// runtime form. - -use std::path::PathBuf; - -use cpex_core::extensions::raw_credentials::TokenRole; -use jsonwebtoken::{Algorithm, DecodingKey}; -use serde::{Deserialize, Serialize}; - -use super::trusted_issuer::{KeyStore, TrustedIssuer}; - -/// Top-level plugin config — what operators write under -/// `plugins[].config:` in unified-config YAML. -/// -/// One instance of this plugin handles ONE inbound credential -/// (one header, one role). Wire multiple instances if a deployment -/// expects multiple inbound tokens — e.g. user JWT in -/// `X-User-Token`, OAuth client token in `Authorization`, and a -/// SPIFFE JWT-SVID in `X-Workload-Token`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JwtIdentityResolverConfig { - /// One or more trusted issuers. At least one required. - pub trusted_issuers: Vec, - - /// Which identity slot this resolver fills. Determines: - /// - /// * Which `TokenRole` key the raw token gets stashed under in - /// `RawCredentialsExtension.inbound_tokens`. - /// * Which `SecurityExtension` slot the mapped identity writes - /// into — `User` → `security.subject`, `Client` → - /// `security.client`, `Workload` → `security.caller_workload`. - /// - /// Default `User` keeps single-resolver deployments backwards- - /// compatible. Custom roles aren't supported yet — the resolver - /// errors at construction. - #[serde(default = "default_role")] - pub role: TokenRole, - - /// HTTP header name this resolver reads its token from - /// (e.g. `"Authorization"`, `"X-User-Token"`). The `Bearer ` - /// prefix is stripped if present. Recorded on - /// `RawInboundToken.source_header` so forwarding plugins can - /// re-attach (or strip) the credential under the same name. - /// Default `Authorization` matches the most common case. - #[serde(default = "default_header")] - pub header: String, - - /// Which claim mapper to use. `"standard"` is the OIDC default; - /// future named mappers (e.g., `"keycloak"`, `"cognito"`) plug - /// in via the registry pattern in `resolver.rs`. Omitted → - /// `StandardClaimMap`. - #[serde(default)] - pub claim_mapper: Option, -} - -fn default_role() -> TokenRole { - TokenRole::User -} - -/// Default JWKS refresh interval — 10 minutes. High enough that a -/// fleet of gateways isn't constantly hammering the IdP; low enough -/// that a routine key rotation propagates within a normal change -/// window. Operators with stricter or laxer needs override per -/// `JwksUrl` via the `refresh_secs` field. -fn default_refresh_secs() -> u64 { - 600 -} - -fn default_header() -> String { - "Authorization".to_string() -} - -/// One issuer's config — issuer URL, audiences, decoding key -/// source, accepted algorithms. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrustedIssuerConfig { - /// Expected `iss` claim value. - pub issuer: String, - - /// Expected audience(s). Empty list disables `aud` validation. - #[serde(default)] - pub audiences: Vec, - - /// Algorithms accepted for signature verification (e.g., - /// `RS256`, `ES256`). At least one required. - pub algorithms: Vec, - - /// Source of the decoding key. See [`DecodingKeySource`]. - pub decoding_key: DecodingKeySource, - - /// Clock-skew tolerance for `exp` / `nbf` validation, in - /// seconds. `0` (default) means "use resolver default" — the - /// constructor applies a sensible value (currently 60s). - #[serde(default)] - pub leeway_seconds: u64, -} - -/// Where the JWT signing key material comes from. Serializable -/// intermediate; the resolver builds a runtime `DecodingKey` from -/// it at construction time. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum DecodingKeySource { - /// Inline PEM-encoded public key (RSA / EC). Useful for tests - /// and dev configs; production deployments usually prefer - /// `pem_file` so keys don't appear in checked-in configs. - Pem { pem: String }, - - /// Path to a PEM file. Read at construction time. Path is - /// resolved relative to the host's working directory unless - /// absolute. - PemFile { path: PathBuf }, - - /// Inline JWK (JSON Web Key) — full JWK structure as JSON. - Jwk { jwk: serde_json::Value }, - - /// OIDC JWKS endpoint — the standard way to wire to a real IdP - /// (Keycloak / Auth0 / Cognito / Okta / Authentik …). Fetched - /// at plugin `initialize()` and re-fetched every `refresh_secs` - /// thereafter so IdP key rolls don't require a gateway - /// restart. Each fetched signature-use key is indexed by its - /// `kid` so the verify path can select the right one per - /// token (overlapping rotation windows work). - /// - /// **`insecure_http`** defaults to `false` — `build_async` - /// rejects `http://` URLs. With JWKS over plaintext, anyone on - /// the network path can swap the key material and forge JWTs - /// the gateway accepts. Set to `true` only for `http://localhost` - /// docker-compose development; production must always use https. - /// - /// **`refresh_secs`** controls how often the background - /// refresh task re-fetches the JWKS. Default 600 (10 minutes) - /// — high enough that a fleet of gateways doesn't hammer the - /// IdP, low enough that a routine key roll propagates within - /// the same business hour. A failed refresh logs a warning - /// and keeps the previous KeyStore — verification continues - /// to work as long as one of the previously-fetched keys - /// matches the inbound token's `kid`. - JwksUrl { - url: String, - #[serde(default)] - insecure_http: bool, - #[serde(default = "default_refresh_secs")] - refresh_secs: u64, - }, - - /// Symmetric HMAC secret (HS256 / HS384 / HS512 only). Not - /// recommended for production; signature verifiers need the - /// same secret, which makes key distribution painful. - Secret { secret: String }, -} - -impl DecodingKeySource { - /// Whether this source needs network I/O to resolve. Used by - /// `JwtIdentityResolver` to decide between eager (sync) build at - /// `new()` and deferred (async) build at `Plugin::initialize()`. - pub fn needs_async(&self) -> bool { - matches!(self, Self::JwksUrl { .. }) - } - - /// How often the background refresh task should re-fetch this - /// source. `Some(_)` for `JwksUrl` (the only refreshable - /// variant), `None` for inline sources whose key material is - /// static for the resolver's lifetime. - pub fn refresh_interval(&self) -> Option { - match self { - Self::JwksUrl { refresh_secs, .. } => { - Some(std::time::Duration::from_secs(*refresh_secs)) - }, - _ => None, - } - } - - /// Synchronously turn the source into a [`KeyStore`]. Works for - /// inline / on-disk sources; **errors for `JwksUrl`** — use - /// [`build_async`] for those. Returns a string error so callers - /// can wrap into `PluginError::Config` with context. - /// - /// Inline sources have no `kid` context, so the resulting store - /// has a single `fallback` entry usable for any token whose - /// header omits `kid`. Tokens that DO carry a `kid` against an - /// inline source resolve to `auth.unknown_kid` at verify time — - /// the JWKS spec is the source of truth for which kids exist. - /// - /// [`build_async`]: Self::build_async - pub fn build(&self) -> Result { - let key = match self { - Self::Pem { pem } => build_from_pem_bytes(pem.as_bytes(), "inline PEM")?, - Self::PemFile { path } => { - let bytes = std::fs::read(path).map_err(|e| { - format!("decoding-key file '{}' unreadable: {e}", path.display()) - })?; - build_from_pem_bytes(&bytes, &format!("file '{}'", path.display()))? - }, - Self::Jwk { jwk } => build_from_jwk_value(jwk)?, - Self::JwksUrl { url, .. } => { - return Err(format!( - "JwksUrl source '{url}' requires async resolution — call build_async()" - )) - }, - Self::Secret { secret } => DecodingKey::from_secret(secret.as_bytes()), - }; - Ok(KeyStore::single_fallback(key)) - } - - /// Asynchronously resolve the source into a [`KeyStore`] — - /// handles every variant including `JwksUrl` (which does an - /// async HTTP GET against the IdP's JWKS endpoint and indexes - /// every signature-use key by its `kid`). - /// - /// Called from `JwtIdentityResolver::initialize()` so the host's - /// PluginManager can drive multiple resolvers' JWKS fetches - /// concurrently via `futures::join_all`. - /// - /// The fetch is bounded by `JWKS_FETCH_TIMEOUT` to prevent a - /// slow or hostile JWKS endpoint from hanging gateway startup - /// indefinitely. A timed-out fetch surfaces as an error string - /// the caller can soft-fail on. - /// - /// **v0 caveat:** - /// - /// * No automatic rotation — the store is bound at initialize - /// time. A background refresh task keeps IdP key - /// rolls from requiring a gateway restart. - pub async fn build_async(&self) -> Result { - match self { - Self::JwksUrl { - url, insecure_http, .. - } => { - // Reject http:// by default. Fetching JWKS over - // plaintext lets anyone on the network path swap the - // signing keys and forge JWTs the gateway accepts. - require_https(url, *insecure_http)?; - - // Build a Client with both a connect timeout and an - // overall request timeout. Without these a slow or - // half-open JWKS endpoint hangs the initialize() call - // indefinitely. The defaults are conservative; if a - // future config wants per-issuer override, add a - // `jwks_timeout_secs` field on `JwksUrl`. - let client = reqwest::Client::builder() - .timeout(JWKS_FETCH_TIMEOUT) - .connect_timeout(JWKS_CONNECT_TIMEOUT) - .build() - .map_err(|e| format!("JWKS client construction failed: {e}"))?; - - let body = client - .get(url) - .send() - .await - .map_err(|e| format!("JWKS GET {url} failed: {e}"))? - .error_for_status() - .map_err(|e| format!("JWKS GET {url} returned non-2xx: {e}"))? - .text() - .await - .map_err(|e| format!("JWKS GET {url} body read failed: {e}"))?; - - let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_str(&body) - .map_err(|e| format!("JWKS {url} body is not a JWKSet: {e}"))?; - - // Iterate every signature-use key (or every key, if - // none declared `use: sig`) and index by `kid`. - // OIDC spec requires JWKS entries to carry a `kid`; - // any entry missing one is dropped with a clear - // diagnostic appended to the error string. If NO - // usable keys remain, treat that as a config error. - let mut entries: Vec<(String, DecodingKey)> = Vec::new(); - let mut skipped_no_kid: usize = 0; - let mut skipped_unusable: Vec = Vec::new(); - for k in &jwks.keys { - // Filter to sig-use when the IdP labels it; if no - // key declares `use`, accept everything (some - // older IdPs publish JWKS without the field). - let use_field = k.common.public_key_use.as_ref(); - if use_field - .map(|u| *u != jsonwebtoken::jwk::PublicKeyUse::Signature) - .unwrap_or(false) - { - continue; - } - let kid = match k.common.key_id.as_deref() { - Some(kid) if !kid.is_empty() => kid.to_string(), - _ => { - skipped_no_kid += 1; - continue; - }, - }; - match DecodingKey::from_jwk(k) { - Ok(key) => entries.push((kid, key)), - Err(e) => skipped_unusable.push(format!("{kid}: {e}")), - } - } - if entries.is_empty() { - return Err(format!( - "JWKS at {url} contained no usable signature keys \ - (skipped {skipped_no_kid} entries with no kid; \ - {} entries failed to parse: [{}])", - skipped_unusable.len(), - skipped_unusable.join(", "), - )); - } - Ok(KeyStore::from_jwks_entries(entries)) - }, - // Non-network variants delegate to the sync path; they - // don't await anything, so the cost is zero vs. a direct - // sync call. - other => other.build(), - } - } -} - -/// Overall request timeout on the JWKS HTTP GET (includes connect + -/// TLS + response body). 5s is a forgiving upper bound for a healthy -/// IdP; anything slower than that is operationally indistinguishable -/// from "JWKS is down." -const JWKS_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - -/// TCP-connect timeout for the JWKS HTTP GET. Separate from the -/// overall timeout so a hostile JWKS endpoint that accepts the -/// connection and then stalls on the response still fails fast. -const JWKS_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); - -/// PEM helper used by both `Pem` and `PemFile`. Tries RSA, then EC, -/// then EdDSA — covers the algorithms `jsonwebtoken` supports. -fn build_from_pem_bytes(bytes: &[u8], origin: &str) -> Result { - DecodingKey::from_rsa_pem(bytes) - .or_else(|_| DecodingKey::from_ec_pem(bytes)) - .or_else(|_| DecodingKey::from_ed_pem(bytes)) - .map_err(|e| format!("{origin} PEM key failed to parse: {e}")) -} - -fn build_from_jwk_value(jwk: &serde_json::Value) -> Result { - let parsed: jsonwebtoken::jwk::Jwk = - serde_json::from_value(jwk.clone()).map_err(|e| format!("JWK is not well-formed: {e}"))?; - DecodingKey::from_jwk(&parsed).map_err(|e| format!("JWK not usable: {e}")) -} - -impl TrustedIssuerConfig { - /// Validate shape (non-empty issuer, at least one algorithm) - /// without resolving the key. Used at construction time as a - /// fast-fail gate so misshapen YAML is rejected before any - /// network I/O is attempted. - pub fn validate(&self) -> Result<(), String> { - if self.issuer.trim().is_empty() { - return Err("trusted_issuer.issuer must be non-empty".into()); - } - if self.algorithms.is_empty() { - return Err(format!( - "trusted_issuer '{}' must list at least one algorithm", - self.issuer - )); - } - Ok(()) - } - - /// Synchronously build a runtime `TrustedIssuer`. Works for - /// inline / on-disk `decoding_key` sources; **errors when - /// `decoding_key.kind == jwks_url`** — use [`build_async`] for - /// those. - /// - /// [`build_async`]: Self::build_async - pub fn build(self) -> Result { - self.validate()?; - let keys = self.decoding_key.build().map_err(|e| { - format!( - "trusted_issuer '{}' decoding_key build failed: {e}", - self.issuer - ) - })?; - Ok(TrustedIssuer { - issuer: self.issuer, - audiences: self.audiences, - keys: std::sync::Arc::new(std::sync::RwLock::new(keys)), - algorithms: self.algorithms, - leeway_seconds: self.leeway_seconds, - }) - } - - /// Asynchronously build a `TrustedIssuer`, handling every - /// `decoding_key` variant including `JwksUrl`. Called from - /// `JwtIdentityResolver::initialize()` for sources that deferred - /// resolution past construction. - pub async fn build_async(self) -> Result { - self.validate()?; - let keys = self.decoding_key.build_async().await.map_err(|e| { - format!( - "trusted_issuer '{}' decoding_key build failed: {e}", - self.issuer - ) - })?; - Ok(TrustedIssuer { - issuer: self.issuer, - audiences: self.audiences, - keys: std::sync::Arc::new(std::sync::RwLock::new(keys)), - algorithms: self.algorithms, - leeway_seconds: self.leeway_seconds, - }) - } -} - -/// Reject `http://` URLs for endpoints that carry trust-establishing -/// material. `https://` is always allowed; `http://` is allowed only -/// when `insecure_http` is `true`. Anything else (missing scheme, -/// data URLs, ...) returns Ok and lets the underlying parser surface -/// its own error. -fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { - let lowered = url.trim_start().to_ascii_lowercase(); - if lowered.starts_with("https://") { - return Ok(()); - } - if lowered.starts_with("http://") { - if insecure_http { - return Ok(()); - } - return Err(format!( - "JWKS URL must use https:// (got '{url}'). Set `insecure_http: true` \ - to allow plaintext for localhost/dev only — never production." - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn jwks_https_accepted() { - assert!(require_https("https://idp.example/realms/x/jwks", false).is_ok()); - } - - #[test] - fn jwks_http_rejected_by_default() { - let err = require_https("http://localhost:8081/jwks", false).unwrap_err(); - assert!(err.contains("https"), "{}", err); - assert!(err.contains("insecure_http"), "{}", err); - } - - #[test] - fn jwks_http_with_explicit_opt_in_allowed() { - assert!(require_https("http://localhost:8081/jwks", true).is_ok()); - } - - #[tokio::test] - async fn jwks_http_url_rejected_at_build_async() { - let src = DecodingKeySource::JwksUrl { - url: "http://idp.example/jwks".into(), - insecure_http: false, - refresh_secs: 3600, - }; - match src.build_async().await { - Err(e) => assert!(e.contains("https"), "{}", e), - Ok(_) => panic!("http:// JWKS URL must not build by default"), - } - } - - #[test] - fn decoding_key_source_secret_builds() { - let src = DecodingKeySource::Secret { - secret: "test-secret".into(), - }; - assert!(src.build().is_ok()); - } - - #[test] - fn decoding_key_source_pem_rejects_garbage() { - // `DecodingKey` doesn't implement Debug (it carries key - // material), so `expect_err` won't compile here — match - // the Err arm directly instead. - let src = DecodingKeySource::Pem { - pem: "not actually pem".into(), - }; - match src.build() { - Err(msg) => assert!(msg.contains("failed to parse")), - Ok(_) => panic!("garbage PEM should have failed"), - } - } - - #[test] - fn config_deserializes_from_json() { - // The shape operators write in unified-config YAML, just - // serialized as JSON for the test. - let raw = json!({ - "trusted_issuers": [{ - "issuer": "https://idp.example.com", - "audiences": ["my-api"], - "algorithms": ["HS256"], - "decoding_key": { - "kind": "secret", - "secret": "test-secret", - }, - "leeway_seconds": 30, - }], - "claim_mapper": "standard", - }); - let cfg: JwtIdentityResolverConfig = serde_json::from_value(raw).unwrap(); - assert_eq!(cfg.trusted_issuers.len(), 1); - assert_eq!(cfg.trusted_issuers[0].issuer, "https://idp.example.com"); - assert_eq!(cfg.claim_mapper.as_deref(), Some("standard")); - } -} diff --git a/builtins/plugins/identity-jwt/src/factory.rs b/builtins/plugins/identity-jwt/src/factory.rs deleted file mode 100644 index a036ebdb..00000000 --- a/builtins/plugins/identity-jwt/src/factory.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/src/factory.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `PluginFactory` impl for the JWT identity resolver. Lives in this -// crate (not in any consuming integration) so that every host — -// Praxis filter, Envoy bridge, CLI test harness — wires it up the -// same way. -// -// Operators declare it in CPEX YAML as: -// -// plugins: -// - name: jwt-resolver -// kind: identity/jwt -// hooks: [identity.resolve] -// config: -// trusted_issuers: -// - issuer: https://idp.example.com -// audiences: [my-api] -// algorithms: [RS256] -// decoding_key: { kind: jwks_url, url: ... } -// -// The `kind: identity/jwt` string is part of this crate's public API. -// Hosts call `mgr.register_factory("identity/jwt", Box::new(JwtIdentityFactory))` -// before `load_config_yaml`. - -use std::sync::Arc; - -use cpex_core::{ - error::PluginError, - factory::{PluginFactory, PluginInstance}, - hooks::TypedHandlerAdapter, - identity::{IdentityHook, HOOK_IDENTITY_RESOLVE}, - plugin::PluginConfig, -}; - -use crate::JwtIdentityResolver; - -/// The plugin `kind:` string operators write in CPEX YAML to declare -/// a JWT identity resolver. -pub const KIND: &str = "identity/jwt"; - -/// Factory for `kind: identity/jwt` plugins. Instantiates a -/// `JwtIdentityResolver` from the `config:` block and registers it on -/// the `identity.resolve` hook. -pub struct JwtIdentityFactory; - -impl PluginFactory for JwtIdentityFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let resolver = Arc::new(JwtIdentityResolver::new(config.clone())?); - let handler = Arc::new(TypedHandlerAdapter::::new(Arc::clone( - &resolver, - ))); - Ok(PluginInstance { - plugin: resolver, - handlers: vec![(HOOK_IDENTITY_RESOLVE, handler)], - }) - } -} diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs deleted file mode 100644 index 8147153c..00000000 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ /dev/null @@ -1,52 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-plugin-identity-jwt — JWT-based `IdentityResolveHandler` for APL. -// -// Validates inbound JWTs against configured trusted issuers and -// maps validated claims into the request's `IdentityPayload` -// (subject / client / raw_credentials slots). The lightweight -// identity path: validate a Bearer token and extract identity, -// independent of any PDP step that runs later in the route. -// -// Scope: data shapes + module structure only. Actual validation -// logic, multi-issuer + key rotation, and integration tests land later. -// -// # Error handling -// -// No bespoke error type. Two surfaces: -// -// * **Build / config errors** — constructors return -// `Result>`. Bad PEM, missing issuer -// URL, etc. surface as `PluginError::Config { message }`. -// * **Runtime token-rejection errors** — handler returns -// `PluginResult::deny(PluginViolation::new(code, reason))`. -// `code` is a stable identifier the host can map to HTTP -// status (`auth.token_expired`, `auth.signature_invalid`, -// `auth.untrusted_issuer`, …); `reason` is the operator- -// readable message. -// -// # When to use this vs alternatives -// -// - **`cpex-plugin-identity-jwt`** (this crate) — JWT-only flow. -// Lightweight, ~5-15 transitive deps. The default choice for -// "validate a Bearer token, extract identity." -// - **Custom resolver** — anyone with bespoke identity flows -// (mTLS-only, opaque tokens with introspection, capability -// tokens) writes their own `HookHandler`. This -// crate's API surface is the reference shape but nothing -// prevents other resolvers from coexisting. - -pub mod claim_map; -pub mod config; -pub mod factory; -pub mod resolver; -pub mod trusted_issuer; - -pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; -pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; -pub use factory::{JwtIdentityFactory, KIND}; -pub use resolver::JwtIdentityResolver; -pub use trusted_issuer::TrustedIssuer; diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs deleted file mode 100644 index 858eae40..00000000 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ /dev/null @@ -1,835 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/src/resolver.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `JwtIdentityResolver` — `HookHandler` that validates -// inbound JWTs and populates the request's `IdentityPayload`. -// -// # Construction -// -// Single entry point: `JwtIdentityResolver::new(cfg: PluginConfig)`. -// Reads `cfg.config` (the typed plugin-specific config field) and -// deserializes it into [`JwtIdentityResolverConfig`], builds the -// runtime `TrustedIssuer` list and the `ClaimMapper`. No alternate -// constructors that bypass the config-driven path — tests -// construct a `PluginConfig` with the right `config` value and go -// through `new` like production code does. -// -// # Runtime flow -// -// 1. Peek at the `iss` claim *without* validating to pick the -// right trusted issuer config. -// 2. Validate the token (signature + exp + nbf + aud + iss) using -// that issuer's `DecodingKey`. `iss` is re-checked here as -// defense-in-depth. -// 3. Map validated claims to a `SubjectExtension` via the -// configured claim mapper. -// 4. Stash the raw token in `RawCredentialsExtension.inbound_tokens` -// under `TokenRole::User` for forwarding plugins downstream. -// 5. Return the updated payload via `PluginResult::modify_payload`. -// -// # Error handling -// -// Construction errors → `Box` (`PluginError::Config`). -// Runtime token rejections → `PluginResult::deny(PluginViolation::new(code, reason))`. -// Stable codes for runtime denials: -// -// * `auth.malformed_header` — JWT structure wrong / empty token -// * `auth.untrusted_issuer` — `iss` not in trusted list -// * `auth.signature_invalid` — signature failed -// * `auth.token_expired` — `exp` in the past -// * `auth.token_not_yet_valid` — `nbf` in the future -// * `auth.audience_mismatch` — `aud` didn't include any configured aud -// * `auth.algorithm_mismatch` — token uses unaccepted algo -// * `auth.mapping_failed` — claim mapper rejected the claims -// * `auth.token_invalid` — any other validation failure - -use std::sync::Arc; - -use async_trait::async_trait; -use base64::Engine; -use jsonwebtoken::{decode, Validation}; -use serde_json::Value; - -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::extensions::raw_credentials::{ - RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, -}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::identity::{IdentityHook, IdentityPayload}; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use super::claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; -use super::config::{JwtIdentityResolverConfig, TrustedIssuerConfig}; -use super::trusted_issuer::{KeyStore, TrustedIssuer}; - -/// Default clock-skew tolerance, in seconds. Matches what most OIDC -/// clients use as a sane default for `exp` / `nbf`. -const DEFAULT_LEEWAY_SECONDS: u64 = 60; - -/// JWT-based identity resolver. See module docs. -/// -/// # Async key resolution -/// -/// Trusted-issuer keys come in two flavors: -/// -/// * **Inline / on-disk** (`Pem`, `PemFile`, `Jwk`, `Secret`) — built -/// eagerly during `new()`. They appear in `trusted_issuers` -/// immediately after construction. -/// * **`JwksUrl`** — deferred to `Plugin::initialize()`. The configs -/// sit in `pending_jwks` until `initialize()` runs; that hook -/// fetches all pending JWKS endpoints **concurrently** via -/// `futures::join_all` and merges the resolved issuers into the -/// `trusted_issuers` vec under the `RwLock`. -/// -/// The split keeps construction synchronous (matches the existing -/// `PluginFactory::create` trait surface across the workspace) while -/// putting the network I/O on the natural async hook the host -/// already drives via `PluginManager::initialize().await`. -#[derive(Debug)] -pub struct JwtIdentityResolver { - cfg: PluginConfig, - trusted_issuers: std::sync::RwLock>, - /// Issuer configs whose `decoding_key` is a `JwksUrl` — - /// resolved during `initialize()`. Empty in deployments with - /// only inline sources. - pending_jwks: Vec, - claim_mapper: Arc, - /// Which identity slot this resolver fills. Drives - /// `IdentityPayload` slot selection and the `TokenRole` key under - /// which the raw token gets stashed in - /// `RawCredentialsExtension.inbound_tokens`. - role: TokenRole, - /// HTTP header this resolver reads its token from - /// (e.g. `X-User-Token`). Plugins that share a request extract - /// from different headers; the value lands on - /// `RawInboundToken.source_header` so forwarding plugins know - /// where to put it (or strip it) on the upstream call. - header: String, - /// Background JWKS-refresh tasks, one per JwksUrl issuer. - /// Spawned during `initialize()`. Aborted in the resolver's - /// `Drop` impl — without that, tokio JoinHandles silently - /// detach the task and the refresh loop runs forever (until - /// the runtime shuts down or it panics). - refresh_tasks: std::sync::Mutex>>, -} - -impl JwtIdentityResolver { - /// Build a resolver from a `PluginConfig`. Reads `cfg.config` - /// (the plugin-specific config field — `Option`), - /// deserializes it into [`JwtIdentityResolverConfig`], builds - /// the runtime `TrustedIssuer` list, and resolves the claim - /// mapper by name. - /// - /// Returns `PluginError::Config` for any config-time failure: - /// missing config block, malformed JSON, no trusted issuers, - /// unparseable decoding key, unknown claim mapper, etc. - pub fn new(cfg: PluginConfig) -> Result> { - let raw_config = cfg.config.as_ref().ok_or_else(|| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-identity-jwt) requires a `config:` block — \ - missing trusted_issuers etc.", - cfg.name - ), - }) - })?; - - let typed: JwtIdentityResolverConfig = - serde_json::from_value(raw_config.clone()).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-identity-jwt) config parse failed: {e}", - cfg.name - ), - }) - })?; - - if typed.trusted_issuers.is_empty() { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-identity-jwt) requires at least one \ - entry in `trusted_issuers`", - cfg.name - ), - })); - } - - // Partition issuer configs: - // * Inline / on-disk decoding keys (Pem, PemFile, Jwk, - // Secret) → eagerly built into TrustedIssuers here. - // * JwksUrl decoding keys → deferred to initialize() so - // the host's PluginManager can drive the HTTP fetches - // concurrently across all resolvers. - let mut trusted_issuers: Vec = Vec::new(); - let mut pending_jwks: Vec = Vec::new(); - for raw in typed.trusted_issuers { - // Validate shape eagerly so bad YAML fails at load_config - // rather than at the async initialize() boundary. - raw.validate().map_err(|e| { - Box::new(PluginError::Config { - message: format!("plugin '{}' (cpex-plugin-identity-jwt): {e}", cfg.name), - }) - })?; - if raw.decoding_key.needs_async() { - pending_jwks.push(raw); - } else { - let built = raw.build().map_err(|e| { - Box::new(PluginError::Config { - message: format!("plugin '{}' (cpex-plugin-identity-jwt): {e}", cfg.name), - }) - })?; - trusted_issuers.push(built); - } - } - - // Resolve the claim mapper by name. Unknown names are a - // config error rather than a silent fallback — fail fast - // so operators notice typos. - let claim_mapper: Arc = match typed.claim_mapper.as_deref() { - None | Some("standard") => Arc::new(StandardClaimMap), - Some(other) => { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-identity-jwt): unknown claim_mapper \ - '{other}'; valid: [standard]", - cfg.name - ), - })); - }, - }; - - // Reject `role: Custom(...)` at construction — the framework - // has slots for User / Client / Workload (the three named - // entries on SecurityExtension). Custom roles would write to - // `inbound_tokens` only, with no SecurityExtension home, so - // downstream `subject.*` / `client.*` predicates wouldn't see - // them. If we ever want custom slots, that's its own slice. - if matches!(typed.role, TokenRole::Custom(_)) { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-identity-jwt): role: Custom(...) is not \ - yet supported — pick one of `user`, `client`, `workload`", - cfg.name - ), - })); - } - if typed.header.trim().is_empty() { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-identity-jwt): `header:` must be a \ - non-empty HTTP header name", - cfg.name - ), - })); - } - - Ok(Self { - cfg, - trusted_issuers: std::sync::RwLock::new(trusted_issuers), - pending_jwks, - claim_mapper, - role: typed.role, - header: typed.header, - refresh_tasks: std::sync::Mutex::new(Vec::new()), - }) - } -} - -impl Drop for JwtIdentityResolver { - /// Stop every background refresh task when the resolver drops. - /// Without this, `tokio::task::JoinHandle` *detaches* on drop - /// — the refresh loop keeps running until the tokio runtime - /// shuts down. That's harmless for the program-lifetime - /// singleton case but creates orphan tasks during plugin - /// hot-reload or in tests that construct/discard resolvers - /// repeatedly. - fn drop(&mut self) { - let mut tasks = match self.refresh_tasks.lock() { - Ok(t) => t, - Err(poisoned) => poisoned.into_inner(), - }; - for handle in tasks.drain(..) { - handle.abort(); - } - } -} - -#[async_trait] -impl Plugin for JwtIdentityResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } - - /// Resolve any `JwksUrl` decoding keys deferred at construction, - /// then spawn a background task per JwksUrl issuer to refresh - /// the KeyStore on a periodic schedule (default 10 min, - /// configurable per-issuer via `refresh_secs`). - /// - /// **Soft-fail semantics:** an unreachable / slow / - /// malformed JWKS at startup logs a warning and leaves the - /// issuer's KeyStore *empty*. The plugin still loads, the - /// gateway still boots, and the background refresh task gets - /// spawned anyway — so a transient IdP outage during boot - /// recovers on its own as soon as refresh succeeds. Verify-time - /// requests against an issuer with an empty KeyStore receive - /// `auth.jwks_unavailable` rather than crashing the request. - /// - /// Initial fetches happen concurrently — N pending issuers - /// → one `join_all`, not N sequential round-trips — so the - /// time-to-ready scales with the slowest IdP, not the sum. - /// - /// The `PluginManager` drives this once per plugin lifetime - /// (before any hooks fire). Idempotent: if `pending_jwks` is - /// empty (no JwksUrl sources) this is a free no-op. - async fn initialize(&self) -> Result<(), Box> { - if self.pending_jwks.is_empty() { - return Ok(()); - } - - // 1. Initial concurrent fetch. Each result is (config, - // outcome) — we keep the config alongside the result - // so the soft-fail path can construct an empty - // KeyStore *and* still spawn refresh for that issuer. - let fetches = self.pending_jwks.iter().cloned().map(|cfg| async move { - let outcome = cfg.clone().build_async().await; - (cfg, outcome) - }); - let resolved: Vec<(TrustedIssuerConfig, Result)> = - futures::future::join_all(fetches).await; - - let mut issuers = self - .trusted_issuers - .write() - .unwrap_or_else(|p| p.into_inner()); - let mut new_tasks: Vec> = Vec::new(); - - for (cfg, outcome) in resolved { - // Get the shared store: from the successful fetch's - // TrustedIssuer if we have one, else an empty store - // bound to a freshly-constructed TrustedIssuer shell. - // Either way we end up with one TrustedIssuer in - // `issuers` and a clone of its `Arc>` - // captured by the refresh task. - let (shared, plugin_name) = (self.cfg.name.clone(), cfg.issuer.clone()); - let issuer = match outcome { - Ok(iss) => iss, - Err(e) => { - tracing::warn!( - plugin = %shared, - issuer = %plugin_name, - error = %e, - "initial JWKS fetch failed; soft-fail. Verify requests \ - against this issuer will receive auth.jwks_unavailable \ - until refresh succeeds." - ); - // Build a TrustedIssuer with an empty KeyStore - // so the refresh task can swap a fresh store in - // without re-running validation logic. - TrustedIssuer { - issuer: cfg.issuer.clone(), - audiences: cfg.audiences.clone(), - keys: Arc::new(std::sync::RwLock::new(KeyStore::empty())), - algorithms: cfg.algorithms.clone(), - leeway_seconds: cfg.leeway_seconds, - } - }, - }; - - // Spawn refresh task. The closure owns: - // - a clone of the source (cfg.decoding_key) for - // re-fetching - // - a clone of the Arc> for atomic - // whole-store replacement on success - // - plugin / issuer names for diagnostic logging - if let Some(interval) = cfg.decoding_key.refresh_interval() { - let source = cfg.decoding_key.clone(); - let shared_store = Arc::clone(&issuer.keys); - let plugin_label = self.cfg.name.clone(); - let issuer_label = cfg.issuer.clone(); - let handle = tokio::spawn(async move { - let mut ticker = tokio::time::interval(interval); - // Skip the first immediate tick — the initial - // fetch already ran synchronously above. The - // first refresh fires at `now + interval`. - ticker.tick().await; - loop { - ticker.tick().await; - match source.build_async().await { - Ok(new_store) => { - // Whole-store replacement. The - // old store drops when the write - // completes — bounded steady-state - // memory regardless of how many - // rotations have happened. - match shared_store.write() { - Ok(mut g) => *g = new_store, - Err(poisoned) => *poisoned.into_inner() = new_store, - } - tracing::info!( - plugin = %plugin_label, - issuer = %issuer_label, - "JWKS refresh succeeded" - ); - }, - Err(e) => { - tracing::warn!( - plugin = %plugin_label, - issuer = %issuer_label, - error = %e, - "JWKS refresh failed; keeping previous KeyStore" - ); - }, - } - } - }); - new_tasks.push(handle); - } - - issuers.push(issuer); - } - - // Park the handles so Drop can abort them. Held under a - // std::sync::Mutex because the resolver's outer methods are - // a mix of sync and async; we don't await while holding it. - let mut tasks = self.refresh_tasks.lock().unwrap_or_else(|p| p.into_inner()); - tasks.extend(new_tasks); - - Ok(()) - } -} - -impl HookHandler for JwtIdentityResolver { - async fn handle( - &self, - payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // Read OUR configured header from the request's full header - // map. HTTP headers are case-insensitive (RFC 7230 §3.2); - // we lowercase the configured name to match the canonical - // form hosts use when populating the map. Fall back to - // `payload.raw_token()` only when no header map is populated - // — covers single-resolver back-compat for hosts that still - // pre-extract one token. - let header_lc = self.header.to_ascii_lowercase(); - let header_value = payload.headers().get(header_lc.as_str()); - let raw_token: String = match header_value { - Some(v) => v.strip_prefix("Bearer ").unwrap_or(v).to_string(), - None if !payload.raw_token().is_empty() => payload.raw_token().to_string(), - None => { - return PluginResult::deny(PluginViolation::new( - "auth.malformed_header", - format!( - "header '{}' missing from request (resolver '{}' / role '{:?}')", - self.header, self.cfg.name, self.role - ), - )); - }, - }; - if raw_token.is_empty() { - return PluginResult::deny(PluginViolation::new( - "auth.malformed_header", - format!("header '{}' is present but empty", self.header), - )); - } - - // 1. Peek at `iss` to find the matching TrustedIssuer config. - let iss = match peek_issuer(&raw_token) { - Some(iss) => iss, - None => { - return PluginResult::deny(PluginViolation::new( - "auth.malformed_header", - "JWT not well-formed or missing `iss` claim", - )); - }, - }; - // Read-lock the issuer list. After `initialize()` it's - // immutable for the resolver's lifetime; reads are cheap. - // Recover from a poisoned lock (a panic somewhere else - // while holding the write lock) — the data is still valid. - let issuers = self - .trusted_issuers - .read() - .unwrap_or_else(|p| p.into_inner()); - let issuer = match issuers.iter().find(|i| i.issuer == iss) { - Some(i) => i, - None => { - return PluginResult::deny(PluginViolation::new( - "auth.untrusted_issuer", - format!("issuer '{iss}' is not in the trusted-issuer list"), - )); - }, - }; - - // 2. Validate signature + standard claims, after kid-driven - // key selection. Three distinct deny codes so operators - // can tell: - // - rotation lag (`auth.unknown_kid`): the IdP rolled - // and our refresh hasn't yet pulled the new key. - // - JWKS-unavailable (`auth.jwks_unavailable`): the - // initial fetch failed and refresh hasn't recovered - // — the gateway didn't crash by design, but it - // also can't verify tokens for this issuer right now. - // - forgery / corruption (`auth.signature_invalid` and - // friends): the standard jsonwebtoken outcomes. - let token_data = match validate_token(&raw_token, issuer) { - Ok(td) => td, - Err(ValidateError::KeysUnavailable) => { - return PluginResult::deny(PluginViolation::new( - "auth.jwks_unavailable", - format!( - "issuer '{iss}' has no signing keys available — \ - initial JWKS fetch failed and refresh has not \ - yet succeeded; check upstream IdP reachability" - ), - )); - }, - Err(ValidateError::UnknownKid(kid)) => { - let reason = match kid { - Some(k) => format!( - "token's header `kid` = '{k}' did not match any key in issuer's JWKS" - ), - None => "token has no `kid` header; issuer's JWKS keys all require kid match" - .to_string(), - }; - return PluginResult::deny(PluginViolation::new("auth.unknown_kid", reason)); - }, - Err(ValidateError::Jwt(e)) => { - let (code, reason) = classify_jwt_error(&e); - return PluginResult::deny(PluginViolation::new(code, reason)); - }, - }; - - // 3. Build the updated payload by mapping claims into the - // typed slot for our configured role. - let mut updated = payload.clone(); - match &self.role { - TokenRole::User => match self.claim_mapper.map_subject(&token_data.claims) { - Some(s) => updated.subject = Some(s), - None => { - return PluginResult::deny(PluginViolation::new( - "auth.mapping_failed", - "claim mapper produced no subject — required `sub` \ - claim missing or wrong shape", - )); - }, - }, - TokenRole::Client => match self.claim_mapper.map_client(&token_data.claims) { - Some(c) => updated.client = Some(c), - None => { - return PluginResult::deny(PluginViolation::new( - "auth.mapping_failed", - "claim mapper produced no client — required `client_id` \ - / `azp` claim missing", - )); - }, - }, - TokenRole::CallerWorkload => match self.claim_mapper.map_workload(&token_data.claims) { - Some(w) => updated.caller_workload = Some(w), - None => { - return PluginResult::deny(PluginViolation::new( - "auth.mapping_failed", - "claim mapper produced no workload — token doesn't look \ - like a SPIFFE-JWT-SVID (sub doesn't start with `spiffe://`)", - )); - }, - }, - TokenRole::Custom(_) => { - // Filtered out at construction; defense in depth. - return PluginResult::deny(PluginViolation::new( - "auth.misconfigured", - "role: Custom(...) is not supported", - )); - }, - // TokenRole is #[non_exhaustive]; future variants must be - // explicitly handled. Until then, treat unknown roles the - // same as Custom — surface as misconfigured rather than - // silently dropping the token. - _ => { - return PluginResult::deny(PluginViolation::new( - "auth.misconfigured", - "unsupported TokenRole variant", - )); - }, - } - - // 4. Stash the raw token for forwarding plugins. Key the - // stash by the resolver's configured role so multi-token - // deployments (user + client + workload) keep each - // credential addressable. - // - // Record the wire format accurately. A Workload token that - // reached this point has already been through - // `map_workload`, which only succeeds on a SPIFFE-shaped - // `sub` — so by construction it is a JWT-SVID, not a - // generic JWT. Consumers that branch on `TokenKind` (audit - // attribution, SPIFFE-aware validation) get the truth - // rather than having to re-parse the token to discover it. - let kind = match self.role { - TokenRole::CallerWorkload => TokenKind::SpiffeJwt, - _ => TokenKind::Jwt, - }; - let mut raw_creds = updated - .raw_credentials - .clone() - .unwrap_or_else(RawCredentialsExtension::default); - raw_creds.inbound_tokens.insert( - self.role.clone(), - RawInboundToken::new(raw_token, self.header.clone(), kind), - ); - updated.raw_credentials = Some(raw_creds); - updated.resolved_at = Some(chrono::Utc::now()); - // Pass the full claim map through `raw_claims` so audit / - // downstream policy that wants uncategorized claims has them. - // For multi-resolver chains, the last resolver wins; if - // operators need per-role raw claims they should read from - // the typed slots (subject.claims / client.claims) instead. - updated.raw_claims = token_data.claims; - - PluginResult::modify_payload(updated) - } -} - -/// Pull the `iss` claim out of a JWT *without* verifying the -/// signature. Used purely to look up which trusted issuer config -/// to validate against next. -/// -/// **Security note:** the value returned here is untrusted until -/// the subsequent validation pass succeeds. We use it only to -/// select the right `DecodingKey`; validation re-enforces `iss` -/// against the matched config. -fn peek_issuer(token: &str) -> Option { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return None; - } - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(parts[1]) - .ok()?; - let value: Value = serde_json::from_slice(&payload_bytes).ok()?; - value.get("iss")?.as_str().map(String::from) -} - -/// Reason `validate_token` couldn't verify the JWT. Wraps the -/// usual `jsonwebtoken::errors::Error` plus the kid-selection -/// and JWKS-availability cases. -enum ValidateError { - /// The JWT's header `kid` didn't match any key the issuer's - /// KeyStore knows about. Distinct from `InvalidSignature` so - /// the verify path can surface `auth.unknown_kid` with the - /// specific kid that was missing — operators can match this - /// against their IdP's currently-published JWKS to confirm - /// rotation propagated. - UnknownKid(Option), - /// The issuer's KeyStore is empty: initial JWKS fetch failed - /// at `initialize()`, refresh task hasn't yet succeeded. The - /// gateway didn't crash (soft-fail by design), but it also - /// can't verify any token from this issuer until refresh - /// catches up. Surfaces as `auth.jwks_unavailable` so - /// operators see "JWKS issue at IdP X" rather than the more - /// alarming `auth.signature_invalid` they'd see if we - /// silently fell back to e.g. an empty key. - KeysUnavailable, - /// jsonwebtoken's own validation outcome (signature, exp, - /// nbf, iss, aud, algorithm). - Jwt(jsonwebtoken::errors::Error), -} - -/// Validate the token against the matched issuer's config: -/// `kid`-driven key selection, then signature, exp, nbf, aud, iss. -/// -/// Two-step lookup: -/// 1. Decode just the JWT header (no signature check yet) to -/// read the `kid` claim. We don't trust the result for -/// authorization decisions — we use it only to pick a -/// candidate key from the issuer's `KeyStore`. -/// 2. If a key is found, run jsonwebtoken's full validation -/// against it. Failure modes (bad sig, expired, etc.) flow -/// through unchanged. -/// 3. If no key matches, return `UnknownKid` — distinct from -/// `InvalidSignature` so operators can tell rotation lag -/// from a forgery attempt at the audit layer. -fn validate_token( - token: &str, - issuer: &TrustedIssuer, -) -> Result, ValidateError> { - let header = jsonwebtoken::decode_header(token).map_err(ValidateError::Jwt)?; - let kid = header.kid.as_deref(); - - // Acquire a read guard on the issuer's KeyStore. The guard is - // held for the duration of `decode()` below — sync, no .await - // between acquire and release, so no risk of deadlock against - // the refresh task's write lock. Refresh writes block until - // outstanding readers release; a verify in flight when refresh - // fires waits a few µs at most. - let keys = issuer - .keys - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - if keys.is_empty() { - return Err(ValidateError::KeysUnavailable); - } - - let key = match keys.select(kid) { - Some(k) => k, - None => return Err(ValidateError::UnknownKid(kid.map(String::from))), - }; - - let primary = issuer.algorithms[0]; - let mut validation = Validation::new(primary); - validation.algorithms = issuer.algorithms.clone(); - validation.set_issuer(&[&issuer.issuer]); - validation.leeway = if issuer.leeway_seconds == 0 { - DEFAULT_LEEWAY_SECONDS - } else { - issuer.leeway_seconds - }; - if issuer.audiences.is_empty() { - validation.validate_aud = false; - } else { - let aud_refs: Vec<&str> = issuer.audiences.iter().map(String::as_str).collect(); - validation.set_audience(&aud_refs); - } - decode::(token, key, &validation).map_err(ValidateError::Jwt) -} - -/// Map jsonwebtoken errors to stable violation codes. -fn classify_jwt_error(e: &jsonwebtoken::errors::Error) -> (&'static str, String) { - use jsonwebtoken::errors::ErrorKind; - let code = match e.kind() { - ErrorKind::ExpiredSignature => "auth.token_expired", - ErrorKind::InvalidSignature => "auth.signature_invalid", - ErrorKind::ImmatureSignature => "auth.token_not_yet_valid", - ErrorKind::InvalidAudience => "auth.audience_mismatch", - ErrorKind::InvalidIssuer => "auth.untrusted_issuer", - ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => "auth.algorithm_mismatch", - ErrorKind::Base64(_) | ErrorKind::Json(_) => "auth.malformed_header", - _ => "auth.token_invalid", - }; - (code, e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use serde_json::json; - - fn jwt_with_payload(payload_json: &str) -> String { - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); - let payload = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); - let sig = URL_SAFE_NO_PAD.encode(b"fake-signature"); - format!("{header}.{payload}.{sig}") - } - - fn cfg_with_config(name: &str, config: Value) -> PluginConfig { - PluginConfig { - name: name.into(), - config: Some(config), - ..Default::default() - } - } - - #[test] - fn new_rejects_missing_config_block() { - let cfg = PluginConfig { - name: "jwt".into(), - config: None, - ..Default::default() - }; - let err = JwtIdentityResolver::new(cfg).expect_err("missing config should fail"); - assert!(format!("{err}").contains("config")); - } - - #[test] - fn new_rejects_empty_trusted_issuers() { - let cfg = cfg_with_config("jwt", json!({ "trusted_issuers": [] })); - let err = JwtIdentityResolver::new(cfg).expect_err("empty trusted_issuers should fail"); - assert!(format!("{err}").contains("trusted_issuers")); - } - - #[test] - fn new_rejects_unknown_claim_mapper() { - let cfg = cfg_with_config( - "jwt", - json!({ - "trusted_issuers": [{ - "issuer": "https://idp.example.com", - "algorithms": ["HS256"], - "decoding_key": { "kind": "secret", "secret": "x" }, - }], - "claim_mapper": "made-up-mapper", - }), - ); - let err = JwtIdentityResolver::new(cfg).expect_err("unknown mapper should fail"); - assert!(format!("{err}").contains("claim_mapper")); - } - - #[test] - fn new_accepts_well_formed_config() { - let cfg = cfg_with_config( - "jwt", - json!({ - "trusted_issuers": [{ - "issuer": "https://idp.example.com", - "audiences": ["my-api"], - "algorithms": ["HS256"], - "decoding_key": { "kind": "secret", "secret": "test-secret" }, - "leeway_seconds": 30, - }], - "claim_mapper": "standard", - }), - ); - let resolver = JwtIdentityResolver::new(cfg).expect("should construct"); - let issuers = resolver.trusted_issuers.read().unwrap(); - assert_eq!(issuers.len(), 1); - assert_eq!(issuers[0].issuer, "https://idp.example.com"); - // Secret source resolves eagerly — no pending JWKS work. - assert!(resolver.pending_jwks.is_empty()); - } - - #[test] - fn peek_issuer_extracts_iss() { - let token = jwt_with_payload(r#"{"sub":"alice","iss":"https://idp.example.com"}"#); - assert_eq!( - peek_issuer(&token), - Some("https://idp.example.com".to_string()), - ); - } - - #[test] - fn peek_issuer_returns_none_for_malformed_token() { - assert!(peek_issuer("not.a-jwt").is_none()); - assert!(peek_issuer("a.b.c.d").is_none()); - assert!(peek_issuer("").is_none()); - } - - #[test] - fn peek_issuer_returns_none_when_iss_missing() { - let token = jwt_with_payload(r#"{"sub":"alice"}"#); - assert!(peek_issuer(&token).is_none()); - } - - #[test] - fn classify_picks_expected_codes() { - use jsonwebtoken::errors::{Error, ErrorKind}; - let cases = [ - (ErrorKind::ExpiredSignature, "auth.token_expired"), - (ErrorKind::InvalidSignature, "auth.signature_invalid"), - (ErrorKind::ImmatureSignature, "auth.token_not_yet_valid"), - (ErrorKind::InvalidAudience, "auth.audience_mismatch"), - (ErrorKind::InvalidIssuer, "auth.untrusted_issuer"), - ]; - for (kind, expected_code) in cases { - let err = Error::from(kind); - let (code, _reason) = classify_jwt_error(&err); - assert_eq!(code, expected_code); - } - } -} diff --git a/builtins/plugins/identity-jwt/src/trusted_issuer.rs b/builtins/plugins/identity-jwt/src/trusted_issuer.rs deleted file mode 100644 index 2f5bdc95..00000000 --- a/builtins/plugins/identity-jwt/src/trusted_issuer.rs +++ /dev/null @@ -1,198 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/src/trusted_issuer.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `TrustedIssuer` — config for one OIDC issuer the resolver trusts, -// plus the `KeyStore` that holds its (possibly-multiple) JWKS keys -// indexed by `kid` for token-header-driven key selection. - -use std::collections::HashMap; - -use jsonwebtoken::{Algorithm, DecodingKey}; - -/// A bundle of decoding keys for one trust anchor, supporting -/// `kid`-driven selection at verify time. -/// -/// JWKS endpoints commonly publish more than one key (rotation grace -/// windows, multi-algo deployments). The standard OIDC pattern is -/// for each token to declare which `kid` it was signed with in its -/// header; verifiers select the matching key from the JWKS rather -/// than picking the first-listed entry and hoping. -/// -/// Two slots: -/// - `by_kid`: keys with a JWKS-declared `kid`. The verify path -/// looks here first using the inbound token's header `kid`. -/// - `fallback`: a single key for the kid-less case. Populated -/// for inline sources (`Pem`/`PemFile`/`Jwk`/`Secret`) which -/// have no JWKS context. JWKS-sourced KeyStores leave this -/// `None` — every JWKS key carries a `kid` by spec. -/// -/// A KeyStore with no entries at all (`by_kid.is_empty() && fallback.is_none()`) -/// is a valid runtime state — it represents "JWKS fetch failed, -/// retry pending" in the soft-fail design. Today every -/// construction path populates at least one slot before the store -/// is reachable from the resolver. -/// -/// # Update discipline (refresh) -/// -/// When the periodic refresh task lands, the intended pattern is -/// **whole-store replacement** — the refresh fetches a fresh JWKS, -/// builds a new `KeyStore`, and replaces the old one atomically -/// (`*shared.write().await = new_store`). Do **not** merge new -/// keys into the existing `by_kid` map: that grows unbounded as -/// the IdP rotates kids in and out over the deployment's lifetime -/// (every kid the IdP ever published stays in our map forever). -/// Whole-store replacement bounds the live key count to the -/// IdP's current JWKS size and lets dropped DecodingKeys release. -/// `RwLock` semantics make this race-free: in-flight verifies -/// holding `&DecodingKey` keep the old store alive until they -/// release, at which point the swap completes and the old store -/// drops. -pub struct KeyStore { - by_kid: HashMap, - fallback: Option, -} - -impl KeyStore { - /// Empty store. Only useful for the soft-fail placeholder path; - /// current code always populates before exposing. - pub fn empty() -> Self { - Self { - by_kid: HashMap::new(), - fallback: None, - } - } - - /// Single-key store with no `kid`. Used by inline sources (Pem, - /// PemFile, Jwk, Secret) — they have no JWKS context to provide - /// a kid, so the key serves every token regardless of header. - pub fn single_fallback(key: DecodingKey) -> Self { - Self { - by_kid: HashMap::new(), - fallback: Some(key), - } - } - - /// Construct from a JWKS — every key gets indexed by its `kid`. - /// JWKS entries without a `kid` are silently dropped (the OIDC - /// spec requires them to carry one; an entry missing `kid` is - /// an IdP misconfiguration we'd rather surface as - /// `auth.unknown_kid` at verify time than as a silent - /// fallback-wins behaviour). - pub fn from_jwks_entries(entries: I) -> Self - where - I: IntoIterator, - { - Self { - by_kid: entries.into_iter().collect(), - fallback: None, - } - } - - /// Look up the key for a token's header `kid`. Returns: - /// - the matching kid'd key if `kid` is Some and present - /// - the fallback if `kid` is None and a fallback exists - /// - None otherwise (caller surfaces `auth.unknown_kid`) - /// - /// Deliberately does NOT silently fall back to `fallback` when - /// a kid'd lookup misses. With both behaviours mixed, an - /// attacker who controls JWKS body order could downgrade a - /// kid'd token to a fallback key. The kid'ed lookup is exact; - /// only kid-absent tokens may use the fallback. - pub fn select(&self, kid: Option<&str>) -> Option<&DecodingKey> { - match kid { - Some(k) => self.by_kid.get(k), - None => self.fallback.as_ref(), - } - } - - /// Diagnostic: how many keys this store knows about. Used in - /// log lines and the `Debug` impl below; not for control flow. - pub fn len(&self) -> usize { - self.by_kid.len() + usize::from(self.fallback.is_some()) - } - - /// Whether the store has any usable key. False only on the - /// Slice-B soft-fail placeholder path. - pub fn is_empty(&self) -> bool { - self.by_kid.is_empty() && self.fallback.is_none() - } -} - -// `DecodingKey` doesn't derive Debug (it carries key bytes; the lib -// avoids accidental log leakage). We elide every key value; only -// the count and kid set surface. -impl std::fmt::Debug for KeyStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let kids: Vec<&str> = self.by_kid.keys().map(String::as_str).collect(); - f.debug_struct("KeyStore") - .field("kids", &kids) - .field("has_fallback", &self.fallback.is_some()) - .finish() - } -} - -/// One issuer's trust config — `iss` value to match against, -/// audience to require, decoding key(s), and acceptable algorithms. -/// -/// Deployments with multiple IdPs construct one of these per IdP -/// and hand the list to `JwtIdentityResolver::new`. The resolver -/// picks the matching issuer based on the inbound token's `iss` -/// claim. -#[non_exhaustive] -pub struct TrustedIssuer { - /// Expected `iss` claim value — the resolver rejects tokens - /// whose `iss` doesn't match. - pub issuer: String, - - /// Expected audience(s). Tokens must carry at least one matching - /// `aud` value. Empty vec means "don't check audience" - /// (only acceptable for trusted-internal flows). - pub audiences: Vec, - - /// Decoding keys for this issuer, indexed by `kid`. For inline - /// sources (Pem/Jwk/Secret) this is a single-entry store with - /// no kid; for JWKS sources every advertised signature key - /// lands here so the verify path can pick the one matching the - /// inbound token's header. - /// - /// Wrapped in `Arc>` so the background JWKS - /// refresh task can atomically swap in a fresh KeyStore - /// without blocking concurrent verifies (read guards are held - /// for the duration of one `decode()`, which is sync — no - /// `.await` between acquisition and release, so no deadlock - /// risk and no contention beyond a few µs per request). - /// - /// Empty during the soft-fail boot path (initial JWKS fetch - /// failed, refresh task will retry). Verify checks for this - /// and returns `auth.jwks_unavailable` rather than the - /// `auth.unknown_kid` it would otherwise produce. - pub keys: std::sync::Arc>, - - /// Algorithms accepted for signature verification. Most - /// deployments stick to one (RS256 most commonly), but - /// supporting multiple lets the IdP rotate to a new algo - /// without us redeploying. - pub algorithms: Vec, - - /// Clock-skew tolerance for `exp` / `nbf` claims, in seconds. - /// Defaults applied in `JwtIdentityResolver::new`. - pub leeway_seconds: u64, -} - -// Manual `Debug` impl — `jsonwebtoken::DecodingKey` doesn't derive -// `Debug` (presumably to avoid leaking key material into logs). -// We elide the key entirely; the issuer URL + algorithms are -// enough for diagnostic output. -impl std::fmt::Debug for TrustedIssuer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TrustedIssuer") - .field("issuer", &self.issuer) - .field("audiences", &self.audiences) - .field("algorithms", &self.algorithms) - .field("leeway_seconds", &self.leeway_seconds) - .field("keys", &self.keys) - .finish() - } -} diff --git a/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs deleted file mode 100644 index 46e2d95e..00000000 --- a/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs +++ /dev/null @@ -1,754 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end test for `DecodingKeySource::JwksUrl` + the async -// resolution path: -// -// 1. Construct a JwtIdentityResolver with `decoding_key.kind: -// jwks_url` pointing at a mockito server. The resolver carries -// the issuer config in `pending_jwks`; `trusted_issuers` is -// empty (no inline keys). -// 2. Call `plugin.initialize().await` — this is the async hook the -// host's `PluginManager::initialize()` drives. It triggers the -// JWKS HTTP fetch. -// 3. Mint a JWT with the corresponding private key, hand it to the -// resolver, assert the subject is populated. Proves the -// fetched JWKS key was wired into the trusted-issuer list. -// -// Also covers: missing-initialize sad path (the resolver returns -// `untrusted_issuer` because the JwksUrl-deferred issuer never made -// it into `trusted_issuers`). - -use std::sync::Arc; - -use cpex_core::hooks::payload::Extensions; -use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; - -use cpex_plugin_identity_jwt::{DecodingKeySource, JwtIdentityResolver}; - -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}; - -const ISS: &str = "https://idp.test.local"; -const AUD: &str = "test-api"; - -/// Build a JWKS JSON document from a single RSA public key. The -/// `kid` is fixed and the key declares `use=sig, alg=RS256` so the -/// resolver picks it via the "first signing-use key" rule. -fn build_jwks(public: &RsaPublicKey) -> Value { - use base64::Engine; - let n_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()); - let e_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()); - json!({ - "keys": [{ - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": "test-key-1", - "n": n_b64, - "e": e_b64, - }] - }) -} - -fn mint_jwt(private_pem: &str, claims: Value) -> String { - // Set `kid` so the resolver's KeyStore lookup hits — the JWKS - // entry exposed by the mock server uses the same kid value - // ("test-key-1", see `jwks_body`). - let mut header = Header::new(Algorithm::RS256); - header.kid = Some("test-key-1".into()); - let key = - EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("build EncodingKey from RSA PEM"); - encode(&header, &claims, &key).expect("sign JWT") -} - -fn now_unix() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64 -} - -fn resolver_config(jwks_url: &str) -> PluginConfig { - PluginConfig { - name: "jwt-via-jwks".into(), - kind: "test".into(), - hooks: vec![HOOK_IDENTITY_RESOLVE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(json!({ - "role": "user", - "header": "Authorization", - "trusted_issuers": [{ - "issuer": ISS, - "audiences": [AUD], - "algorithms": ["RS256"], - // mockito serves over http://127.0.0.1 — opt in to - // plaintext for this test. Production deployments - // must omit `insecure_http`. - "decoding_key": { "kind": "jwks_url", "url": jwks_url, "insecure_http": true }, - "leeway_seconds": 60, - }], - "claim_mapper": "standard", - })), - ..Default::default() - } -} - -/// Verify that a JWT signed by the JWKS-published key validates -/// after `initialize()` resolves the JWKS URL. -#[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 = OsRng; - let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); - let pub_key = RsaPublicKey::from(&priv_key); - let priv_pem = priv_key - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(); - let jwks_body = build_jwks(&pub_key).to_string(); - // Suppress unused-import warning on EncodeRsaPublicKey — only - // exists to keep the trait in scope for callers that want - // alternate PEM exports. - let _ = pub_key.to_pkcs1_pem(LineEnding::LF); - - let mut server = Server::new_async().await; - let mock = server - .mock("GET", "/realms/test/protocol/openid-connect/certs") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks_body) - .expect(1) - .create_async() - .await; - - let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); - - // 2. Build the resolver. JwksUrl source → trusted_issuers is - // empty until initialize() runs. - let cfg = resolver_config(&jwks_url); - let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); - - // 3. Wire into a PluginManager and call initialize. The - // manager's initialize() drives plugin.initialize(), which - // triggers the async JWKS fetch. - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::clone(&resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - mgr.initialize().await.expect("initialize succeeds"); - - // 4. Mint a JWT, dispatch, assert subject populated. - let token = mint_jwt( - &priv_pem, - json!({ - "sub": "alice@corp.com", - "iss": ISS, - "aud": AUD, - "exp": now_unix() + 300, - "iat": now_unix(), - "roles": ["hr"], - }), - ); - - let mut headers = std::collections::HashMap::new(); - headers.insert("Authorization".to_string(), format!("Bearer {token}")); - - let payload = IdentityPayload::new(token.clone(), TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers); - - let (result, _bg) = mgr - .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) - .await; - assert!( - result.continue_processing, - "valid JWT (JWKS-resolved key) should pass: violation = {:?}", - result.violation - ); - let identity = - IdentityPayload::from_pipeline_result(&result).expect("identity payload present"); - let subject = identity.subject.as_ref().expect("subject populated"); - assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); - assert!(subject.roles.contains("hr")); - - // 5. The mock recorded one (and only one) GET — proves we did - // a real network fetch. - mock.assert_async().await; -} - -/// Without `initialize()`, the issuer config sits in `pending_jwks` -/// and `trusted_issuers` is empty — a token signed by the JWKS key -/// gets `auth.untrusted_issuer` rather than silently passing. This -/// is the deliberate fail-loud mode: hosts must call -/// `PluginManager::initialize()`. -#[tokio::test(flavor = "multi_thread")] -async fn skipping_initialize_rejects_with_untrusted_issuer() { - 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 - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(); - - let mut server = Server::new_async().await; - let _mock = server - .mock("GET", "/realms/test/protocol/openid-connect/certs") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(build_jwks(&pub_key).to_string()) - // We expect ZERO calls — the test never calls initialize. - .expect(0) - .create_async() - .await; - - let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); - let cfg = resolver_config(&jwks_url); - let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); - - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::clone(&resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - // Deliberately SKIP mgr.initialize() — we want to prove the - // pending JwksUrl issuer never made it into trusted_issuers. - - let token = mint_jwt( - &priv_pem, - json!({ - "sub": "alice", - "iss": ISS, - "aud": AUD, - "exp": now_unix() + 300, - }), - ); - let mut headers = std::collections::HashMap::new(); - headers.insert("Authorization".to_string(), format!("Bearer {token}")); - - let payload = IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers); - let (result, _bg) = mgr - .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) - .await; - assert!( - !result.continue_processing, - "no initialize() should yield deny (JWKS issuer never wired)", - ); - let v = result.violation.expect("violation should be reported"); - assert_eq!(v.code, "auth.untrusted_issuer"); -} - -// ===================================================================== -// kid-based key selection + JWKS fetch timeout -// ===================================================================== - -/// Build a JWKS containing two RSA keys with distinct `kid`s. Used by -/// the rotation / kid-selection tests below to prove the resolver -/// picks the key matching the inbound token's header, not the first -/// listed. -fn build_jwks_two_keys( - pub_a: &RsaPublicKey, - kid_a: &str, - pub_b: &RsaPublicKey, - kid_b: &str, -) -> Value { - use base64::Engine; - let make_entry = |k: &RsaPublicKey, kid: &str| { - json!({ - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": kid, - "n": base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(k.n().to_bytes_be()), - "e": base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(k.e().to_bytes_be()), - }) - }; - json!({ - "keys": [ - make_entry(pub_a, kid_a), - make_entry(pub_b, kid_b), - ] - }) -} - -/// Mint a JWT with a specific `kid` in the header. Distinct from -/// `mint_jwt` (which uses the default test kid) so the kid-selection -/// tests can control which key the resolver should select. -fn mint_jwt_with_kid(private_pem: &str, kid: &str, claims: Value) -> String { - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(kid.into()); - let key = - EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("build EncodingKey from RSA PEM"); - encode(&header, &claims, &key).expect("sign JWT") -} - -/// JWKS publishes two keys with distinct kids. A token signed by -/// key B with header `kid=key-b` must validate against key B, not -/// against the first-listed key A. Pre-Slice-A code would pick the -/// 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 = 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); - let pub_b = RsaPublicKey::from(&priv_b); - let priv_pem_b = priv_b - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM b") - .to_string(); - - let jwks_body = build_jwks_two_keys(&pub_a, "key-a", &pub_b, "key-b").to_string(); - - let mut server = Server::new_async().await; - let _mock = server - .mock("GET", "/realms/test/protocol/openid-connect/certs") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks_body) - .create_async() - .await; - let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); - - let cfg = resolver_config(&jwks_url); - let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::clone(&resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - mgr.initialize().await.expect("initialize"); - - // Token signed by B, with kid=key-b. The resolver must select - // key B from the JWKS (not first-listed key A). - let token = mint_jwt_with_kid( - &priv_pem_b, - "key-b", - json!({ - "sub": "alice", - "iss": ISS, - "aud": AUD, - "exp": now_unix() + 300, - "iat": now_unix(), - }), - ); - let mut headers = std::collections::HashMap::new(); - headers.insert("Authorization".into(), format!("Bearer {token}")); - let payload = IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers); - let (result, _) = mgr - .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) - .await; - assert!( - result.continue_processing, - "kid-matched token must verify: violation = {:?}", - result.violation, - ); -} - -/// Token's `kid` header doesn't match any key the JWKS knows about. -/// Must yield `auth.unknown_kid` — distinct from -/// `auth.signature_invalid` so operators can tell rotation lag -/// from forgery at the audit layer. -#[tokio::test(flavor = "multi_thread")] -async fn unknown_kid_yields_unknown_kid_violation() { - 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 - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(); - - // JWKS publishes a single key with kid=test-key-1. - let jwks_body = build_jwks(&pub_key).to_string(); - let mut server = Server::new_async().await; - let _mock = server - .mock("GET", "/realms/test/protocol/openid-connect/certs") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(jwks_body) - .create_async() - .await; - let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); - - let cfg = resolver_config(&jwks_url); - let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::clone(&resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - mgr.initialize().await.expect("initialize"); - - // Token signed by the right private key, but its header - // declares `kid=stale-key` — which is what the IdP would do - // post-rotation if we haven't refreshed yet. - let token = mint_jwt_with_kid( - &priv_pem, - "stale-key", - json!({ - "sub": "alice", - "iss": ISS, - "aud": AUD, - "exp": now_unix() + 300, - "iat": now_unix(), - }), - ); - let mut headers = std::collections::HashMap::new(); - headers.insert("Authorization".into(), format!("Bearer {token}")); - let payload = IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers); - let (result, _) = mgr - .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) - .await; - assert!(!result.continue_processing); - let v = result.violation.expect("violation reported"); - assert_eq!(v.code, "auth.unknown_kid"); - assert!( - v.reason.contains("stale-key"), - "reason should name the missing kid: {}", - v.reason, - ); -} - -/// JWKS endpoint accepts the TCP connection but stalls indefinitely -/// on the HTTP response — the kind of slow-loris pattern a hostile -/// or simply broken IdP could exhibit. The fetch must time out -/// rather than hanging `initialize()` forever. -#[tokio::test(flavor = "multi_thread")] -async fn jwks_fetch_times_out_when_endpoint_stalls() { - use std::time::Duration; - use tokio::io::AsyncWriteExt; - - // Stand up a tiny TCP listener that accepts connections, reads - // the request headers, and then deliberately never sends a - // response body. The JWKS fetch should give up after the - // configured timeout (~5s) rather than waiting forever. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind ephemeral"); - let addr = listener.local_addr().expect("listener addr"); - tokio::spawn(async move { - while let Ok((mut sock, _)) = listener.accept().await { - tokio::spawn(async move { - // Drain a bit of request data, then send a partial - // status line and stop. Reqwest will sit waiting - // for body bytes that never arrive. - let mut buf = [0u8; 512]; - let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await; - let _ = sock - .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n") - .await; - // Hold the connection open without writing the - // 100-byte body. Sleep beyond the resolver's - // overall timeout to confirm timeout-not-receive. - tokio::time::sleep(Duration::from_secs(15)).await; - }); - } - }); - - let url = format!("http://{addr}/jwks"); - let src = DecodingKeySource::JwksUrl { - url: url.clone(), - insecure_http: true, - refresh_secs: 3600, - }; - - let started = std::time::Instant::now(); - let outcome = src.build_async().await; - let elapsed = started.elapsed(); - - // The wall-clock bound is the load-bearing assertion: a slow - // / hostile JWKS must not hang `build_async` indefinitely. The - // exact error string reqwest surfaces for a deadline elapsed - // varies across platforms and reqwest versions — sometimes - // "timeout", sometimes "body read failed: error decoding - // response body" (when the body stream gets cut by the - // deadline). We accept any Err outcome and rely on elapsed - // time as the contract. - match outcome { - Err(_e) => {}, - Ok(_store) => panic!("stalled JWKS must not produce a KeyStore"), - } - // 5s overall timeout + 2s margin for setup / scheduler jitter. - assert!( - elapsed < Duration::from_secs(8), - "fetch should have given up promptly; took {elapsed:?}", - ); -} - -// ===================================================================== -// soft-fail at boot + periodic JWKS refresh -// ===================================================================== - -/// JWKS endpoint is unreachable at gateway boot. The plugin must -/// `initialize()` cleanly (no Err — soft-fail) so the gateway -/// doesn't crash on a transient IdP outage. Subsequent verify -/// calls against tokens for that issuer must surface -/// `auth.jwks_unavailable` — a clear, distinct code so operators -/// see "JWKS issue at IdP X" rather than the alarming -/// `auth.signature_invalid` they'd see if we silently used an -/// empty key. -#[tokio::test(flavor = "multi_thread")] -async fn jwks_unreachable_at_initialize_soft_fails() { - let mut rng = OsRng; - let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); - let priv_pem = priv_key - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(); - - // Point at 127.0.0.1:1 — port 1 isn't bound by typical systems, - // so the TCP connect fails fast. The fetch timeout would also - // catch a slow endpoint; here we just want "unreachable." - let jwks_url = "http://127.0.0.1:1/jwks".to_string(); - let cfg = resolver_config(&jwks_url); - let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::clone(&resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - - // The gateway boots — initialize returns Ok even though the - // JWKS fetch failed. This is the soft-fail invariant. - mgr.initialize() - .await - .expect("initialize must NOT propagate JWKS failure"); - - // A token signed by the right key fails verify with - // `auth.jwks_unavailable` rather than crashing or returning - // the wrong code. The resolver's KeyStore is empty until - // refresh succeeds (which it won't, in this test). - let token = mint_jwt_with_kid( - &priv_pem, - "test-key-1", - json!({ - "sub": "alice", - "iss": ISS, - "aud": AUD, - "exp": now_unix() + 300, - "iat": now_unix(), - }), - ); - let mut headers = std::collections::HashMap::new(); - headers.insert("Authorization".into(), format!("Bearer {token}")); - let payload = IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers); - let (result, _) = mgr - .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) - .await; - assert!(!result.continue_processing); - let v = result.violation.expect("violation reported"); - assert_eq!(v.code, "auth.jwks_unavailable"); - assert!( - v.reason.contains(ISS), - "reason should name the affected issuer: {}", - v.reason, - ); -} - -/// Initial JWKS publishes key A; the mock then rotates to key B. -/// A token signed by B with `kid=key-b` is initially rejected -/// (KeyStore only knows A). After the refresh interval ticks, -/// the resolver's KeyStore swaps in B and the same token -/// validates. Pins both: -/// - that refresh runs without restart -/// - that whole-store replacement actually swaps (not merges, -/// not silently drops the update) -#[tokio::test(flavor = "multi_thread")] -async fn jwks_refresh_picks_up_rotated_key() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::Duration; - - 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); - let pub_b = RsaPublicKey::from(&priv_b); - let priv_pem_b = priv_b - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM b") - .to_string(); - - let jwks_a = build_jwks(&pub_a).to_string(); - let jwks_b = { - use base64::Engine; - json!({ - "keys": [{ - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": "key-b", - "n": base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(pub_b.n().to_bytes_be()), - "e": base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(pub_b.e().to_bytes_be()), - }] - }) - .to_string() - }; - - // Track how many times the JWKS endpoint has been hit so we - // can flip the response body after the first fetch. - let fetch_count = Arc::new(AtomicUsize::new(0)); - - let mut server = Server::new_async().await; - let count_for_mock = Arc::clone(&fetch_count); - let jwks_b_clone = jwks_b.clone(); - let _mock = server - .mock("GET", "/realms/test/protocol/openid-connect/certs") - .with_status(200) - .with_header("content-type", "application/json") - .with_body_from_request(move |_req| { - let n = count_for_mock.fetch_add(1, Ordering::SeqCst); - if n == 0 { - jwks_a.clone().into_bytes() - } else { - jwks_b_clone.clone().into_bytes() - } - }) - .expect_at_least(2) - .create_async() - .await; - let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); - - // Resolver config with a short refresh — 1 second keeps the - // test wall-clock low. The default 600s wouldn't fire inside - // the test window. Built inline rather than via - // `resolver_config(...)` because we need the `refresh_secs` - // field which the shared helper doesn't expose. - let cfg = PluginConfig { - name: "jwt-via-jwks".into(), - kind: "test".into(), - hooks: vec![HOOK_IDENTITY_RESOLVE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(json!({ - "role": "user", - "header": "Authorization", - "trusted_issuers": [{ - "issuer": ISS, - "audiences": [AUD], - "algorithms": ["RS256"], - "decoding_key": { - "kind": "jwks_url", - "url": jwks_url, - "insecure_http": true, - "refresh_secs": 1, - }, - "leeway_seconds": 60, - }], - "claim_mapper": "standard", - })), - ..Default::default() - }; - - let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::clone(&resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - mgr.initialize().await.expect("initialize"); - - // Token signed by B, with kid=key-b. Pre-refresh, the - // resolver only knows key A → `auth.unknown_kid`. - let make_payload = || { - let token = mint_jwt_with_kid( - &priv_pem_b, - "key-b", - json!({ - "sub": "alice", - "iss": ISS, - "aud": AUD, - "exp": now_unix() + 300, - "iat": now_unix(), - }), - ); - let mut headers = std::collections::HashMap::new(); - headers.insert("Authorization".into(), format!("Bearer {token}")); - IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers) - }; - - let (pre, _) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - make_payload(), - Extensions::default(), - None, - ) - .await; - assert!( - !pre.continue_processing, - "key-b token should not validate before refresh" - ); - assert_eq!( - pre.violation.expect("violation").code, - "auth.unknown_kid", - "pre-refresh: kid mismatch should report unknown_kid", - ); - - // Wait long enough for the refresh task to fire at least once. - // 1s refresh interval + a generous margin for scheduler jitter. - // Poll the same verify in a loop until it succeeds or we time - // out — avoids a flaky fixed sleep. - let deadline = std::time::Instant::now() + Duration::from_secs(8); - let mut succeeded = false; - while std::time::Instant::now() < deadline { - tokio::time::sleep(Duration::from_millis(200)).await; - let (r, _) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - make_payload(), - Extensions::default(), - None, - ) - .await; - if r.continue_processing { - succeeded = true; - break; - } - } - assert!( - succeeded, - "refresh task should have swapped in key-b within 8s of a 1s-interval refresh", - ); -} diff --git a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs deleted file mode 100644 index 2e67c40a..00000000 --- a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs +++ /dev/null @@ -1,492 +0,0 @@ -// Location: ./builtins/plugins/identity-jwt/tests/jwt_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end tests for `JwtIdentityResolver` against a real RSA -// keypair + signed JWTs. Exercises the full handler path: -// `mgr.invoke_named::(...)` → resolver decodes / -// validates / maps claims → host extracts the populated -// `IdentityPayload` via `from_pipeline_result`. -// -// Scenarios: -// * happy path: valid signed token resolves to a populated subject -// * untrusted issuer (token signed correctly but `iss` not in config) -// * expired token (`exp` in the past) -// * audience mismatch -// * signature tamper -// -// Keypair is generated once per test process (RSA 2048 takes -// ~50-100ms; one-time cost) and shared across tests via OnceLock. - -use std::sync::Arc; -use std::sync::OnceLock; - -use cpex_core::extensions::raw_credentials::{TokenKind, TokenRole}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; -use cpex_core::manager::PluginManager; -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}; - -const TEST_ISSUER: &str = "https://idp.test.local"; -const TEST_AUDIENCE: &str = "test-api"; - -// ===================================================================== -// Test fixtures -// ===================================================================== - -struct Keypair { - private_pem: String, - public_pem: String, -} - -/// Process-global keypair. Generated once on first access; RSA 2048 -/// is ~50-100ms which we don't want to pay per-test. -fn keypair() -> &'static Keypair { - static KP: OnceLock = OnceLock::new(); - KP.get_or_init(|| { - let mut rng = OsRng; - let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); - let pub_key = RsaPublicKey::from(&priv_key); - Keypair { - private_pem: priv_key - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(), - public_pem: pub_key - .to_public_key_pem(LineEnding::LF) - .expect("encode public PEM"), - } - }) -} - -/// Sign `claims` as an RS256 JWT using the test private key. JWT -/// payload is whatever JSON the caller hands in. -fn mint_jwt(claims: Value) -> String { - use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; - let header = Header::new(Algorithm::RS256); - let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) - .expect("build EncodingKey from test private PEM"); - encode(&header, &claims, &key).expect("sign JWT") -} - -/// Construct a `PluginConfig` whose `config:` block declares the -/// test public key as the trusted-issuer signing material. Mirrors -/// what an operator writes in unified-config YAML. -fn resolver_plugin_config() -> PluginConfig { - let plugin_config = json!({ - "trusted_issuers": [{ - "issuer": TEST_ISSUER, - "audiences": [TEST_AUDIENCE], - "algorithms": ["RS256"], - "decoding_key": { - "kind": "pem", - "pem": keypair().public_pem, - }, - "leeway_seconds": 60, - }], - "claim_mapper": "standard", - }); - PluginConfig { - name: "jwt-resolver".into(), - kind: "test".into(), - hooks: vec![HOOK_IDENTITY_RESOLVE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(plugin_config), - ..Default::default() - } -} - -/// Role-aware variant of [`resolver_plugin_config`]. `role` and -/// `header` are the two knobs that decide which identity slot a -/// resolver instance fills and where it reads its token from — one -/// instance per inbound credential, so a deployment expecting a user -/// JWT *and* a workload SVID wires two. -fn resolver_plugin_config_for(role: &str, header: &str) -> PluginConfig { - let mut cfg = resolver_plugin_config(); - match cfg.config.as_mut() { - Some(Value::Object(map)) => { - map.insert("role".into(), json!(role)); - map.insert("header".into(), json!(header)); - }, - other => panic!("resolver config should be a JSON object, got {other:?}"), - } - cfg -} - -/// Build the PluginManager + register the resolver + initialize. -/// All four scenarios share this skeleton. -async fn build_manager() -> Arc { - build_manager_with(resolver_plugin_config()).await -} - -async fn build_manager_with(cfg: PluginConfig) -> Arc { - let resolver = JwtIdentityResolver::new(cfg.clone()).expect("resolver should construct"); - - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler_for_names::( - Arc::new(resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - mgr.initialize().await.unwrap(); - mgr -} - -/// Run a token through the full handler pipeline. -async fn invoke(token: String) -> cpex_core::executor::PipelineResult { - invoke_with(resolver_plugin_config(), token, TokenSource::Bearer).await -} - -async fn invoke_with( - cfg: PluginConfig, - token: String, - source: TokenSource, -) -> cpex_core::executor::PipelineResult { - let mgr = build_manager_with(cfg).await; - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - IdentityPayload::new(token, source), - Extensions::default(), - None, - ) - .await; - result -} - -fn now_unix() -> i64 { - chrono::Utc::now().timestamp() -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Happy path: valid signed token resolves to a populated subject, -/// raw token lands in `raw_credentials.inbound_tokens[User]`. -#[tokio::test] -async fn valid_jwt_resolves_subject() { - let token = mint_jwt(json!({ - "sub": "alice@corp.com", - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - "iat": now_unix(), - "roles": ["hr", "reader"], - "email": "alice@corp.com", - })); - - let result = invoke(token.clone()).await; - assert!( - result.continue_processing, - "valid token should resolve: violation = {:?}", - result.violation, - ); - - let identity = - IdentityPayload::from_pipeline_result(&result).expect("payload should be present"); - let subject = identity.subject.as_ref().expect("subject populated"); - assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); - assert!(subject.roles.contains("hr")); - assert!(subject.roles.contains("reader")); - // `email` was not a reserved claim, lands under subject.claims - assert_eq!( - subject.claims.get("email"), - Some(&"alice@corp.com".to_string()), - ); - - // Raw token stashed for forwarding plugins. - let raw = identity - .raw_credentials - .as_ref() - .expect("raw_credentials populated"); - let user_token = raw - .inbound_tokens - .get(&TokenRole::User) - .expect("user-role token present"); - assert_eq!(&*user_token.token, &token); - assert!(matches!(user_token.kind, TokenKind::Jwt)); -} - -// --------------------------------------------------------------------- -// Workload role — SPIFFE JWT-SVID ingress -// --------------------------------------------------------------------- - -/// A resolver configured with `role: workload` is the ingress for the -/// caller's SPIFFE JWT-SVID. It must land the mapped identity in -/// `caller_workload` (the *calling agent*, distinct from the gateway's -/// own `this_workload`) and stash the raw bytes under -/// `TokenRole::CallerWorkload` — the slot a `delegate(...)` step reads from -/// when a route says `subject: workload` or `actor: workload`. -/// -/// The stash is tagged `TokenKind::SpiffeJwt`, not the generic `Jwt`: -/// reaching this point means `map_workload` already accepted the -/// SPIFFE-shaped `sub`, so the wire format is known, and consumers -/// that branch on kind shouldn't have to re-parse the token to learn -/// what the resolver already established. -#[tokio::test] -async fn workload_svid_resolves_caller_workload_and_stashes_as_spiffe_jwt() { - let svid = mint_jwt(json!({ - // SPIFFE JWT-SVID convention: the SPIFFE ID lives in `sub`. - "sub": "spiffe://corp.example/ns/default/sa/payroll-agent", - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - "iat": now_unix(), - })); - - let result = invoke_with( - resolver_plugin_config_for("workload", "X-Workload-Token"), - svid.clone(), - TokenSource::SpiffeJwtSvid, - ) - .await; - assert!( - result.continue_processing, - "valid SVID should resolve: violation = {:?}", - result.violation, - ); - - let identity = - IdentityPayload::from_pipeline_result(&result).expect("payload should be present"); - - // Lands in caller_workload — the inbound peer — not subject. - let workload = identity - .caller_workload - .as_ref() - .expect("caller_workload populated"); - assert_eq!( - workload.spiffe_id.as_deref(), - Some("spiffe://corp.example/ns/default/sa/payroll-agent"), - ); - assert_eq!(workload.trust_domain.as_deref(), Some("corp.example")); - assert!( - identity.subject.is_none(), - "a workload-role resolver must not populate the user slot", - ); - - // Stashed under the Workload role, tagged as a SPIFFE JWT-SVID, - // and attributed to the header it arrived on. - let raw = identity - .raw_credentials - .as_ref() - .expect("raw_credentials populated"); - let workload_token = raw - .inbound_tokens - .get(&TokenRole::CallerWorkload) - .expect("workload-role token present"); - assert_eq!(&*workload_token.token, &svid); - assert_eq!(workload_token.source_header, "X-Workload-Token"); - assert!( - matches!(workload_token.kind, TokenKind::SpiffeJwt), - "workload SVID should be tagged SpiffeJwt, got {:?}", - workload_token.kind, - ); -} - -/// A `role: workload` resolver handed a perfectly valid *user* JWT -/// must refuse it rather than filing a non-SPIFFE identity into the -/// workload slot. Guards the boundary that makes `subject: workload` -/// meaningful: whatever is in that slot really is an attested -/// workload. -#[tokio::test] -async fn workload_role_rejects_a_non_spiffe_token() { - let user_jwt = mint_jwt(json!({ - "sub": "alice@corp.com", // no spiffe:// prefix - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - "iat": now_unix(), - })); - - let result = invoke_with( - resolver_plugin_config_for("workload", "X-Workload-Token"), - user_jwt, - TokenSource::SpiffeJwtSvid, - ) - .await; - - assert!( - !result.continue_processing, - "a non-SPIFFE token must not resolve as a workload", - ); - let violation = result.violation.expect("violation surfaced"); - assert_eq!(violation.code, "auth.mapping_failed"); -} - -/// The `spiffe_id` fallback must be prefix-checked too: a non-SPIFFE -/// `sub` combined with an arbitrary `spiffe_id` claim must NOT be -/// accepted as a workload. Without the guard on the fallback, this token -/// would be mislabeled `TokenKind::SpiffeJwt` and land in caller_workload. -#[tokio::test] -async fn workload_role_rejects_non_spiffe_sub_with_bogus_spiffe_id_claim() { - let jwt = mint_jwt(json!({ - "sub": "alice@corp.com", // not a SPIFFE ID - "spiffe_id": "not-a-spiffe-id", // arbitrary, non-SPIFFE fallback - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - "iat": now_unix(), - })); - - let result = invoke_with( - resolver_plugin_config_for("workload", "X-Workload-Token"), - jwt, - TokenSource::SpiffeJwtSvid, - ) - .await; - - assert!( - !result.continue_processing, - "a non-SPIFFE sub must not be rescued by a bogus spiffe_id claim", - ); - assert_eq!( - result.violation.expect("violation surfaced").code, - "auth.mapping_failed", - ); -} - -/// The legit fallback still resolves: when `sub` isn't a SPIFFE ID but a -/// valid `spiffe://` lives in the `spiffe_id` claim, the workload is -/// accepted. Guards the fix from over-restricting. -#[tokio::test] -async fn workload_role_accepts_valid_spiffe_id_claim_fallback() { - let jwt = mint_jwt(json!({ - "sub": "svc-account-123", // not SPIFFE - "spiffe_id": "spiffe://corp.example/ns/default/sa/agent", // valid SPIFFE fallback - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - "iat": now_unix(), - })); - - let result = invoke_with( - resolver_plugin_config_for("workload", "X-Workload-Token"), - jwt, - TokenSource::SpiffeJwtSvid, - ) - .await; - - assert!( - result.continue_processing, - "a valid spiffe_id claim fallback must resolve the workload", - ); -} - -/// Token correctly signed by the test key but its `iss` doesn't -/// match any trusted issuer in our config → `auth.untrusted_issuer`. -/// This is the path where the peek-at-iss step does its job. -#[tokio::test] -async fn untrusted_issuer_rejects() { - let token = mint_jwt(json!({ - "sub": "alice", - "iss": "https://hacker.example.com", // not in trusted_issuers list - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - })); - - let result = invoke(token).await; - assert!(!result.continue_processing); - let v = result.violation.expect("rejection should surface"); - assert_eq!(v.code, "auth.untrusted_issuer"); -} - -/// `exp` claim is one hour in the past → `auth.token_expired`. -/// Leeway is 60s so a 1h-stale token is unambiguously rejected. -#[tokio::test] -async fn expired_token_rejects() { - let token = mint_jwt(json!({ - "sub": "alice", - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() - 3600, - })); - - let result = invoke(token).await; - assert!(!result.continue_processing); - let v = result.violation.expect("rejection should surface"); - assert_eq!(v.code, "auth.token_expired"); -} - -/// `aud` doesn't match the configured audience → `auth.audience_mismatch`. -#[tokio::test] -async fn wrong_audience_rejects() { - let token = mint_jwt(json!({ - "sub": "alice", - "iss": TEST_ISSUER, - "aud": "some-other-api", // not the configured TEST_AUDIENCE - "exp": now_unix() + 300, - })); - - let result = invoke(token).await; - assert!(!result.continue_processing); - let v = result.violation.expect("rejection should surface"); - assert_eq!(v.code, "auth.audience_mismatch"); -} - -/// Tamper with the signature bytes → signature verification fails → -/// `auth.signature_invalid`. The load-bearing test for the security -/// story; if this passes, the cryptographic validation is wired -/// correctly through the whole pipeline. -#[tokio::test] -async fn tampered_signature_rejects() { - let valid = mint_jwt(json!({ - "sub": "alice", - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - })); - // Flip a char in the middle of the signature segment. We - // can't tamper with the *last* char because base64url - // encoding of a 256-byte RSA-2048 signature requires its last - // char to encode 4 trailing-bit zeros — only `{A, Q, g, w}` - // satisfy that. A naive flip to an out-of-set char produces - // invalid base64 (decoder error → `auth.malformed_header`) - // rather than valid bytes that fail signature verification. - // Middle-segment chars don't have the trailing-bit constraint. - let parts: Vec<&str> = valid.split('.').collect(); - assert_eq!(parts.len(), 3, "JWT should have three segments"); - let sig = parts[2]; - let mut sig_chars: Vec = sig.chars().collect(); - let target_idx = sig_chars.len() / 2; // well into the middle - let original = sig_chars[target_idx]; - // Pick a replacement that's different but in the same charset. - let replacement = if original == 'A' { 'B' } else { 'A' }; - sig_chars[target_idx] = replacement; - let new_sig: String = sig_chars.into_iter().collect(); - let tampered = format!("{}.{}.{}", parts[0], parts[1], new_sig); - - let result = invoke(tampered).await; - assert!(!result.continue_processing); - let v = result.violation.expect("rejection should surface"); - assert_eq!(v.code, "auth.signature_invalid"); -} - -/// Token with no `iss` claim at all → `auth.malformed_header` from -/// the peek step (we can't pick a trusted issuer without `iss`). -#[tokio::test] -async fn missing_iss_rejects() { - let token = mint_jwt(json!({ - "sub": "alice", - // no iss - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - })); - - let result = invoke(token).await; - assert!(!result.continue_processing); - let v = result.violation.expect("rejection should surface"); - assert_eq!(v.code, "auth.malformed_header"); -} diff --git a/builtins/plugins/pii-scanner/Cargo.toml b/builtins/plugins/pii-scanner/Cargo.toml deleted file mode 100644 index e2bbfed5..00000000 --- a/builtins/plugins/pii-scanner/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -# Location: ./builtins/plugins/pii-scanner/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# cpex-plugin-pii-scanner — CMF plugin that detects PII patterns (SSN, -# credit card, email) in tool/prompt/resource args and either denies -# the call, taints the session, or redacts the matching values. - -[package] -name = "cpex-plugin-pii-scanner" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX CMF plugin — detects and redacts PII (SSN, credit card, email) in tool/prompt args." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -cpex-core = { workspace = true } - -async-trait = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -regex = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } - -[lints] -workspace = true diff --git a/builtins/plugins/pii-scanner/src/config.rs b/builtins/plugins/pii-scanner/src/config.rs deleted file mode 100644 index abf24ac2..00000000 --- a/builtins/plugins/pii-scanner/src/config.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Location: ./builtins/plugins/pii-scanner/src/config.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor - -use serde::{Deserialize, Serialize}; - -/// Plugin config — what operators write under -/// `plugins[].config:` in unified-config YAML. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PiiScannerConfig { - /// Which patterns to detect. Defaults to `[ssn, credit_card]` - /// which covers the common high-signal cases. - #[serde(default = "default_detect")] - pub detect: Vec, - - /// What to do when a match is found. - #[serde(default)] - pub mode: PiiScanMode, -} - -fn default_detect() -> Vec { - vec![PiiPattern::Ssn, PiiPattern::CreditCard] -} - -/// Built-in PII pattern catalog. Patterns chosen for high signal-to- -/// noise on the kinds of values that flow through agent tool calls. -/// Operators can supply a custom regex via `PiiPattern::Custom`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum PiiPattern { - /// US Social Security Number: `NNN-NN-NNNN`. - Ssn, - /// Credit-card-like sequences (13-19 digits, optional separators). - /// Note: does NOT Luhn-check — for v0 the regex match is enough - /// to flag. Luhn validation is a future refinement. - CreditCard, - /// Email address. Surprisingly common false-positive risk — - /// operators turn this off if their tools legitimately deal in - /// email addresses (HR directory, contact lists). - Email, - /// Operator-supplied regex. Useful for company-specific IDs - /// (employee IDs that aren't already public, internal account - /// numbers, etc.). - Custom { name: String, regex: String }, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PiiScanMode { - /// Return `pii.detected` violation — gateway translates to 403. - /// The strictest mode; the request never reaches downstream. - #[default] - Deny, - /// Replace each matching value with `[PII]` in the outbound - /// payload. Lets the request through but with secrets neutered. - Redact, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn defaults() { - let cfg: PiiScannerConfig = serde_json::from_value(json!({})).unwrap(); - assert_eq!(cfg.detect.len(), 2); - assert!(matches!(cfg.mode, PiiScanMode::Deny)); - } - - #[test] - fn parse_full_config() { - let raw = json!({ - "detect": [ - { "kind": "ssn" }, - { "kind": "custom", "name": "internal_id", "regex": "^INT-[A-Z0-9]{10}$" } - ], - "mode": "redact", - }); - let cfg: PiiScannerConfig = serde_json::from_value(raw).unwrap(); - assert_eq!(cfg.detect.len(), 2); - assert!(matches!(cfg.mode, PiiScanMode::Redact)); - } -} diff --git a/builtins/plugins/pii-scanner/src/factory.rs b/builtins/plugins/pii-scanner/src/factory.rs deleted file mode 100644 index e6abdc10..00000000 --- a/builtins/plugins/pii-scanner/src/factory.rs +++ /dev/null @@ -1,69 +0,0 @@ -// Location: ./builtins/plugins/pii-scanner/src/factory.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor - -use std::sync::Arc; - -use cpex_core::{ - cmf::CmfHook, - error::PluginError, - factory::{PluginFactory, PluginInstance}, - hooks::TypedHandlerAdapter, - plugin::PluginConfig, -}; - -use crate::scanner::PiiScanner; - -/// `kind:` string operators write in CPEX YAML to declare a PII -/// scanner instance. -pub const KIND: &str = "validator/pii-scan"; - -/// Factory for `kind: validator/pii-scan`. Instantiates a -/// `PiiScanner` from the `config:` block and registers a handler -/// for every CMF hook name listed in `cfg.hooks`. Operators -/// typically wire it on `cmf.tool_pre_invoke` / -/// `cmf.prompt_pre_invoke` / `cmf.resource_pre_fetch` so it runs -/// before any of those entity types reach the backend. -pub struct PiiScannerFactory; - -impl PluginFactory for PiiScannerFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let scanner = Arc::new(PiiScanner::new(config.clone())?); - - // Register the same handler instance against every CMF hook - // name the operator declared in YAML — same plugin, multiple - // entry points. Empty hooks list is a config error. - if config.hooks.is_empty() { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-pii-scanner): `hooks:` must list at \ - least one CMF hook to scan on (e.g. cmf.tool_pre_invoke)", - config.name - ), - })); - } - - let handlers: Vec<_> = config - .hooks - .iter() - .map(|h| -> (&'static str, _) { - // Leak the string to get a 'static lifetime — the - // handler registry stores it that way for cheap - // comparison. PluginConfigs are read once at startup - // and live for the process lifetime, so the leak - // bound is the number of plugin × hook pairs in - // config (small, bounded). - let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); - let adapter: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&scanner))); - (leaked, adapter) - }) - .collect(); - - Ok(PluginInstance { - plugin: scanner, - handlers, - }) - } -} diff --git a/builtins/plugins/pii-scanner/src/lib.rs b/builtins/plugins/pii-scanner/src/lib.rs deleted file mode 100644 index 231167ae..00000000 --- a/builtins/plugins/pii-scanner/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Location: ./builtins/plugins/pii-scanner/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// cpex-plugin-pii-scanner — CMF `HookHandler` that walks the message's -// ToolCall / PromptRequest argument map and tests each string value -// against configured PII patterns. Modes: -// -// * `deny` — return `pii.detected` violation; gateway 403s -// * `taint` — emit a session taint label (downstream policy can -// gate via `session.labels contains 'PII'`) -// * `redact` — replace matching values with `[PII]` and continue -// -// Operators wire it as a `policy:` step: -// -// policy: -// - "require(perm.email_send)" -// - "plugin(pii-scan)" -// -// The plugin registers on whichever CMF pre-invoke hooks the -// operator declares in YAML (tool / prompt / llm / resource). - -pub mod config; -pub mod factory; -pub mod scanner; - -pub use config::{PiiPattern, PiiScanMode, PiiScannerConfig}; -pub use factory::{PiiScannerFactory, KIND}; -pub use scanner::PiiScanner; diff --git a/builtins/plugins/pii-scanner/src/scanner.rs b/builtins/plugins/pii-scanner/src/scanner.rs deleted file mode 100644 index b2248867..00000000 --- a/builtins/plugins/pii-scanner/src/scanner.rs +++ /dev/null @@ -1,323 +0,0 @@ -// Location: ./builtins/plugins/pii-scanner/src/scanner.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor - -use std::sync::Arc; - -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use crate::config::{PiiPattern, PiiScanMode, PiiScannerConfig}; - -/// CMF plugin that walks the message's ToolCall / PromptRequest / -/// ResourceRef arguments and tests each string value against the -/// configured PII patterns. -#[derive(Debug)] -pub struct PiiScanner { - cfg: PluginConfig, - typed: PiiScannerConfig, - /// Compiled regexes paired with the pattern name (for violation - /// attribution). Compiled once at construction; matched per call. - patterns: Vec<(String, Regex)>, -} - -impl PiiScanner { - pub fn new(cfg: PluginConfig) -> Result> { - let raw = cfg.config.as_ref().ok_or_else(|| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-pii-scanner) requires a `config:` block", - cfg.name - ), - }) - })?; - let typed: PiiScannerConfig = serde_json::from_value(raw.clone()).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-pii-scanner) config parse failed: {e}", - cfg.name - ), - }) - })?; - - let patterns = compile_patterns(&typed.detect, &cfg.name)?; - Ok(Self { - cfg, - typed, - patterns, - }) - } - - /// Scan every string value in the message's structured content - /// (ToolCall.arguments, PromptRequest.arguments) plus any text - /// parts. Returns the name of the first matching pattern, or - /// `None` if no match. The pattern name flows into the violation - /// code so audit logs say `pii.detected: ssn` rather than - /// generic `pii.detected`. - fn first_match(&self, message: &Message) -> Option<&str> { - for part in &message.content { - match part { - ContentPart::ToolCall { content } => { - for v in content.arguments.values() { - if let Some(name) = self.match_value(v) { - return Some(name); - } - } - }, - ContentPart::PromptRequest { content } => { - for v in content.arguments.values() { - if let Some(name) = self.match_value(v) { - return Some(name); - } - } - }, - ContentPart::Text { text } => { - if let Some(name) = self.match_str(text) { - return Some(name); - } - }, - _ => {}, // images / video / audio / etc. — out of scope for v0 - } - } - None - } - - fn match_value(&self, v: &Value) -> Option<&str> { - match v { - Value::String(s) => self.match_str(s), - // Numbers / bools can't carry PII patterns. Arrays / - // objects could be walked recursively in a future - // version; for now we only flag flat string fields, - // which covers the common LLM tool-call shape. - _ => None, - } - } - - fn match_str(&self, s: &str) -> Option<&str> { - for (name, re) in &self.patterns { - if re.is_match(s) { - return Some(name); - } - } - None - } - - /// Rewrite the message's content: replace any string value that - /// matches a pattern with `[PII]`. Used in `redact` mode. - fn redact_message(&self, message: &mut Message) { - for part in message.content.iter_mut() { - match part { - ContentPart::ToolCall { content } => { - for v in content.arguments.values_mut() { - self.redact_value(v); - } - }, - ContentPart::PromptRequest { content } => { - for v in content.arguments.values_mut() { - self.redact_value(v); - } - }, - ContentPart::Text { text } => { - if self.match_str(text).is_some() { - *text = "[PII]".to_string(); - } - }, - _ => {}, - } - } - } - - fn redact_value(&self, v: &mut Value) { - if let Value::String(s) = v { - if self.match_str(s).is_some() { - *v = Value::String("[PII]".to_string()); - } - } - } -} - -fn compile_patterns( - patterns: &[PiiPattern], - plugin_name: &str, -) -> Result, Box> { - let mut out = Vec::with_capacity(patterns.len()); - for p in patterns { - let (name, re_str) = match p { - PiiPattern::Ssn => ("ssn", r"\b\d{3}-\d{2}-\d{4}\b".to_string()), - PiiPattern::CreditCard => ( - "credit_card", - // 13-19 digit sequences with optional spaces / hyphens - // every 4 digits. Liberal — Luhn validation would - // tighten this but isn't needed for the demo signal. - r"\b(?:\d[ -]?){13,19}\b".to_string(), - ), - PiiPattern::Email => ( - "email", - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b".to_string(), - ), - PiiPattern::Custom { name, regex } => (name.as_str(), regex.clone()), - }; - let re = Regex::new(&re_str).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{plugin_name}' (cpex-plugin-pii-scanner): pattern '{name}' \ - failed to compile: {e}" - ), - }) - })?; - out.push((name.to_string(), re)); - } - Ok(out) -} - -#[async_trait] -impl Plugin for PiiScanner { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for PiiScanner { - async fn handle( - &self, - payload: &MessagePayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let hit = self.first_match(&payload.message); - match (hit, self.typed.mode) { - (None, _) => PluginResult::allow(), - (Some(pattern_name), PiiScanMode::Deny) => PluginResult::deny(PluginViolation::new( - "pii.detected", - format!( - "PII pattern '{pattern_name}' detected in request \ - args — refusing to forward to downstream" - ), - )), - (Some(_), PiiScanMode::Redact) => { - let mut updated = payload.clone(); - self.redact_message(&mut updated.message); - PluginResult::modify_payload(updated) - }, - } - } -} - -// Silence unused-import in case a feature is added later that needs -// Arc — kept for parity with how other crates structure their imports. -#[allow(dead_code)] -fn _force_link_arc(_: Arc<()>) {} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::cmf::{Role, ToolCall}; - use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; - use serde_json::json; - use std::collections::HashMap; - - fn cfg(detect: Vec, mode: PiiScanMode) -> PluginConfig { - let cfg_json = serde_json::to_value(PiiScannerConfig { detect, mode }).unwrap(); - PluginConfig { - name: "pii-scan".into(), - kind: "test".into(), - hooks: vec!["cmf.tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(cfg_json), - ..Default::default() - } - } - - fn message_with_args(args: HashMap) -> MessagePayload { - MessagePayload { - message: Message::with_content( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "1".into(), - name: "send_email".into(), - arguments: args, - namespace: None, - }, - }], - ), - } - } - - #[tokio::test] - async fn ssn_in_args_denied() { - let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Deny)).unwrap(); - let payload = message_with_args(HashMap::from([( - "body".to_string(), - json!("Her SSN is 555-12-3456"), - )])); - let mut ctx = PluginContext::default(); - let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; - assert!(!r.continue_processing, "should deny"); - let v = r.violation.expect("violation present"); - assert_eq!(v.code, "pii.detected"); - assert!(v.reason.contains("ssn")); - } - - #[tokio::test] - async fn clean_args_allowed() { - let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Deny)).unwrap(); - let payload = message_with_args(HashMap::from([( - "body".to_string(), - json!("Quarterly compensation review summary."), - )])); - let mut ctx = PluginContext::default(); - let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; - assert!(r.continue_processing); - assert!(r.modified_payload.is_none()); - } - - #[tokio::test] - async fn redact_mode_rewrites_value() { - let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Redact)).unwrap(); - let payload = message_with_args(HashMap::from([ - ("body".to_string(), json!("555-12-3456")), - ("subject".to_string(), json!("payroll question")), - ])); - let mut ctx = PluginContext::default(); - let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; - assert!(r.continue_processing, "redact allows; doesn't deny"); - let modified = r.modified_payload.expect("payload was modified"); - let args = match &modified.message.content[0] { - ContentPart::ToolCall { content } => &content.arguments, - _ => panic!("expected ToolCall"), - }; - assert_eq!(args["body"], json!("[PII]")); - // Untouched fields preserved. - assert_eq!(args["subject"], json!("payroll question")); - } - - #[tokio::test] - async fn custom_pattern() { - let p = PiiScanner::new(cfg( - vec![PiiPattern::Custom { - name: "internal_id".into(), - regex: r"^INT-[A-Z0-9]{6}$".into(), - }], - PiiScanMode::Deny, - )) - .unwrap(); - let payload = message_with_args(HashMap::from([("ref".to_string(), json!("INT-ABC123"))])); - let mut ctx = PluginContext::default(); - let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; - assert!(!r.continue_processing); - let v = r.violation.expect("violation present"); - assert!(v.reason.contains("internal_id")); - } -} diff --git a/builtins/session/valkey/Cargo.toml b/builtins/session/valkey/Cargo.toml deleted file mode 100644 index 6e4db817..00000000 --- a/builtins/session/valkey/Cargo.toml +++ /dev/null @@ -1,71 +0,0 @@ -# Location: ./builtins/session/valkey/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Fred Araujo -# -# cpex-session-valkey — a Valkey-backed `SessionStore` for distributed, -# cross-restart persistence of session security labels. -# -# # Dependency discipline -# -# This crate is OPTIONAL and feature-gated into cpex-ffi (`valkey` feature) -# and excluded from the workspace `default-members`, so the Valkey client + -# its TLS/async stack never land in the default FFI artifact or everyday -# `cargo build`. The redis client is pinned with -# `default-features = false` and a rustls TLS path to stay openssl-free, -# matching the `reqwest = { features = ["rustls-tls"] }` discipline in -# cpex-plugin-identity-jwt / cpex-plugin-delegator-oauth. - -[package] -name = "cpex-session-valkey" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "CPEX session store — Valkey/Redis-backed distributed session labels." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -apl-cpex = { workspace = true } -async-trait = { workspace = true } -serde = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -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). -url = "2" -# Async Valkey/Redis client. `default-features = false` drops the command -# groups we don't use (acl/geo/script/streams/json). `tokio-rustls-comp` -# selects the rustls TLS path (tokio-rustls + rustls), keeping the tree -# openssl-free; `connection-manager` adds reconnect-with-backoff. -redis = { version = "1.2", default-features = false, features = [ - "aio", - "tokio-comp", - "tokio-rustls-comp", - "connection-manager", -] } -# External async connection pool over redis-rs. Forward the rustls TLS -# feature explicitly so the rediss:// path is robust even if feature -# unification with the redis dep ever changes. -deadpool-redis = { version = "0.23", default-features = false, features = [ - "rt_tokio_1", - "tokio-rustls-comp", -] } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } -# Spins a real `valkey/valkey` container for integration tests; these are -# `#[ignore]`d by default and run via a dedicated job (see tests/). -testcontainers-modules = { version = "0.14", features = ["valkey"] } -testcontainers = "0.26" - -[lints] -workspace = true diff --git a/builtins/session/valkey/src/config.rs b/builtins/session/valkey/src/config.rs deleted file mode 100644 index 064a0432..00000000 --- a/builtins/session/valkey/src/config.rs +++ /dev/null @@ -1,391 +0,0 @@ -// Location: ./builtins/session/valkey/src/config.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Parses and validates the `global.apl.session_store` block for the -// Valkey backend. Deliberately minimal: a single endpoint, TLS, -// auth, key prefix, optional sliding TTL, and fail-closed timeout/retry -// knobs with committed safe defaults. Sentinel/Cluster fields are NOT -// present — they are out of scope and would be dead config surface. - -use serde::Deserialize; - -use crate::error::BuildError; - -/// Default key prefix/namespace for the label keyspace. The `v1` segment -/// lets a future value-schema change bump the namespace cleanly. -fn default_key_prefix() -> String { - "taint:v1".to_string() -} - -// Committed fail-closed defaults (see plan Key Technical Decisions). They -// ship in code so behavior and tests are deterministic; operators tune -// from this baseline. -fn default_connect_timeout_ms() -> u64 { - 250 -} -fn default_command_timeout_ms() -> u64 { - 500 -} - -/// Parsed `global.apl.session_store` config for `kind: valkey`. -/// -/// Unknown keys (including `kind`, consumed by the factory dispatch) are -/// ignored so the same block can carry the discriminator. -#[derive(Debug, Clone, Deserialize)] -pub struct ValkeyConfig { - /// Endpoint: a `redis://` / `rediss://` URL or a bare `host:port`. - pub endpoint: String, - - /// Whether to use TLS. Implied `true` for a `rediss://` endpoint. - /// Required for any non-localhost endpoint (validated). - #[serde(default)] - pub tls: bool, - - /// Optional ACL username (Valkey 6+ ACLs). Paired with `password`. - #[serde(default)] - pub username: Option, - - /// Optional auth password / ACL secret. Sourced from config/env by - /// the operator; never hard-coded. - #[serde(default)] - pub password: Option, - - /// Key prefix/namespace for label keys. - #[serde(default = "default_key_prefix")] - pub key_prefix: String, - - /// Sliding TTL in seconds, refreshed on load and append. `None` - /// (default) means no expiry. - #[serde(default)] - pub ttl_seconds: Option, - - /// Declared maximum session-identity lifetime, used only to emit the - /// TTL-soundness warning when `ttl_seconds` is shorter. - #[serde(default)] - pub max_session_lifetime_seconds: Option, - - /// Connection acquisition timeout (ms). - #[serde(default = "default_connect_timeout_ms")] - pub connect_timeout_ms: u64, - - /// Per-command response timeout (ms) — the fail-closed hot-path knob. - #[serde(default = "default_command_timeout_ms")] - pub command_timeout_ms: u64, - // NOTE: bounded retry + circuit-breaker are deliberately NOT implemented - // in v0 (deferred follow-up). The store fails closed on the first - // backend error, which is safe — it just fails faster. A `max_retries` - // knob is intentionally absent rather than present-but-dead, so config - // never advertises behavior the code doesn't have. -} - -impl ValkeyConfig { - /// Parse from the YAML config block, then validate. - pub fn from_value(value: &serde_yaml::Value) -> Result { - let cfg: ValkeyConfig = - serde_yaml::from_value(value.clone()).map_err(|e| BuildError::Config(e.to_string()))?; - cfg.validate()?; - Ok(cfg) - } - - /// Enforce the non-negotiable invariants. TLS is mandatory off - /// localhost; a `tls: true` + plaintext `redis://` scheme is a - /// contradiction (would connect in cleartext); the connection URL - /// must build; the TTL-soundness warning is emitted here. - /// - /// All error text routes the endpoint through [`redact_endpoint`] so - /// embedded credentials never leak into errors or logs. - fn validate(&self) -> Result<(), BuildError> { - // A fully-formed plaintext `redis://` endpoint with `tls: true` - // is contradictory: tls_enabled() would say "secure" while the - // explicit scheme forces cleartext. Reject rather than silently - // connecting in the clear. - if self.tls && self.endpoint.starts_with("redis://") { - return Err(BuildError::Config(format!( - "`tls: true` conflicts with the plaintext `redis://` scheme in endpoint '{}'; \ - use a `rediss://` URL or a bare host:port", - redact_endpoint(&self.endpoint) - ))); - } - - if !self.tls_enabled() && !endpoint_is_localhost(&self.endpoint) { - return Err(BuildError::TlsRequired(redact_endpoint(&self.endpoint))); - } - - // Credential-consistency checks, rejected loud at config-load rather - // than silently mis-connecting on first request. - // - // 1. A full `redis://`/`rediss://` endpoint carries its own - // credentials; the separate `username`/`password` fields are - // ignored for URL endpoints (connection_url returns early). Setting - // both is ambiguous — force credentials into one place. - let endpoint_is_url = - self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://"); - if endpoint_is_url && (self.username.is_some() || self.password.is_some()) { - return Err(BuildError::Config(format!( - "endpoint '{}' is a full URL; put credentials in the URL userinfo \ - (rediss://user:pass@host) or use a bare host:port — the separate \ - `username`/`password` fields are ignored for URL endpoints", - redact_endpoint(&self.endpoint) - ))); - } - - // 2. A `username` with no `password` would silently connect as the - // default user with the username dropped. Reject the ambiguity. - if self.username.is_some() && self.password.is_none() { - return Err(BuildError::Config( - "`username` is set without a `password`; supply a `password` for the ACL \ - user, or remove `username` to connect as the default user" - .to_string(), - )); - } - - // Build the URL now so a malformed endpoint / unencodable - // credential fails at config-load, not on first request. - self.connection_url()?; - - if let (Some(ttl), Some(life)) = (self.ttl_seconds, self.max_session_lifetime_seconds) { - if ttl < life { - tracing::warn!( - alarm = "session_store_ttl_unsound", - ttl_seconds = ttl, - max_session_lifetime_seconds = life, - "valkey session_store TTL is shorter than the declared max session lifetime; \ - accumulated taint can silently expire (downgrade-by-waiting)" - ); - } - } - Ok(()) - } - - /// TLS is on when explicitly set or implied by a `rediss://` scheme. - pub fn tls_enabled(&self) -> bool { - self.tls || self.endpoint.starts_with("rediss://") - } - - /// Build the `redis`/`rediss` connection URL deadpool consumes. - /// - /// Credentials are percent-encoded via the `url` crate (never naive - /// string interpolation), and the wire scheme always reflects - /// [`Self::tls_enabled`] so it cannot disagree with the validated TLS - /// intent. A fully-formed endpoint URL is parsed (and trusted for its - /// own embedded credentials); a bare `host:port` is assembled with - /// the configured scheme and any separate `username`/`password`. - pub fn connection_url(&self) -> Result { - if self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://") { - // Validate it parses; trust the operator's embedded scheme + - // credentials. (validate() has already rejected the - // tls:true + redis:// contradiction.) - let url = url::Url::parse(&self.endpoint).map_err(|e| { - BuildError::Config(format!( - "invalid endpoint URL '{}': {e}", - redact_endpoint(&self.endpoint) - )) - })?; - return Ok(url.to_string()); - } - - let scheme = if self.tls_enabled() { - "rediss" - } else { - "redis" - }; - let mut url = url::Url::parse(&format!("{scheme}://{}", self.endpoint)).map_err(|e| { - BuildError::Config(format!( - "invalid endpoint '{}': {e}", - redact_endpoint(&self.endpoint) - )) - })?; - // Apply credentials when either is present. `validate()` guarantees a - // `username` is always paired with a `password`; a lone `password` - // (default-user AUTH) stays valid and sets an empty username. - if self.username.is_some() || self.password.is_some() { - // set_username/set_password percent-encode and reject hosts - // that cannot carry userinfo (e.g. cannot-be-a-base URLs). - url.set_username(self.username.as_deref().unwrap_or("")) - .map_err(|_| BuildError::Config("endpoint cannot carry credentials".to_string()))?; - if let Some(password) = &self.password { - url.set_password(Some(password)).map_err(|_| { - BuildError::Config("endpoint cannot carry credentials".to_string()) - })?; - } - } - Ok(url.to_string()) - } -} - -/// Strip any `userinfo` (`user:pass@`) from an endpoint before it appears -/// in an error message or log line, so credentials are never disclosed. -fn redact_endpoint(endpoint: &str) -> String { - if let Some(scheme_end) = endpoint.find("://") { - let (scheme, after) = (&endpoint[..scheme_end], &endpoint[scheme_end + 3..]); - if let Some(at) = after.rfind('@') { - return format!("{scheme}://***@{}", &after[at + 1..]); - } - return endpoint.to_string(); - } - // Bare host:port may still carry userinfo if misconfigured. - if let Some(at) = endpoint.rfind('@') { - return format!("***@{}", &endpoint[at + 1..]); - } - endpoint.to_string() -} - -/// Best-effort localhost check for the TLS-required rule. Strips scheme, -/// credentials, and port, then matches the common loopback hosts. -fn endpoint_is_localhost(endpoint: &str) -> bool { - let no_scheme = endpoint - .strip_prefix("rediss://") - .or_else(|| endpoint.strip_prefix("redis://")) - .unwrap_or(endpoint); - // Drop any credentials before the host. - let host_port = no_scheme.rsplit('@').next().unwrap_or(no_scheme); - // Bracketed IPv6 loopback, e.g. [::1]:6379. - if host_port.starts_with("[::1]") { - return true; - } - let host = host_port.split(':').next().unwrap_or(host_port); - matches!(host, "localhost" | "127.0.0.1" | "::1") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse(yaml: &str) -> Result { - let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - ValkeyConfig::from_value(&v) - } - - #[test] - fn localhost_without_tls_is_allowed() { - let cfg = parse("kind: valkey\nendpoint: localhost:6379\n").unwrap(); - assert_eq!(cfg.key_prefix, "taint:v1"); - assert_eq!(cfg.connect_timeout_ms, 250); - assert_eq!(cfg.command_timeout_ms, 500); - assert!(cfg - .connection_url() - .unwrap() - .starts_with("redis://localhost:6379")); - } - - #[test] - fn non_localhost_without_tls_is_rejected() { - let err = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\n").unwrap_err(); - assert!(matches!(err, BuildError::TlsRequired(_)), "got {err:?}"); - } - - #[test] - fn non_localhost_with_tls_uses_rediss_scheme() { - let cfg = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\n").unwrap(); - assert!(cfg.tls_enabled()); - assert!(cfg - .connection_url() - .unwrap() - .starts_with("rediss://valkey.prod.internal:6379")); - } - - #[test] - fn rediss_scheme_implies_tls() { - let cfg = parse("kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\n").unwrap(); - assert!(cfg.tls_enabled()); - assert!(cfg.connection_url().unwrap().starts_with("rediss://")); - } - - /// Regression for the TLS-bypass finding: `tls: true` with an explicit - /// plaintext `redis://` scheme must be rejected, not silently connect - /// in the clear. - #[test] - fn tls_true_with_plaintext_scheme_is_rejected() { - let err = parse("kind: valkey\nendpoint: redis://valkey.prod.internal:6379\ntls: true\n") - .unwrap_err(); - assert!(matches!(err, BuildError::Config(_)), "got {err:?}"); - } - - #[test] - fn credentials_are_percent_encoded_in_url() { - // A password with URL-significant characters must be encoded, not - // interpolated raw (which would corrupt the URL). - let cfg = parse( - "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gw\npassword: \"p@ss:w/rd\"\n", - ) - .unwrap(); - let url = cfg.connection_url().unwrap(); - assert!(url.starts_with("rediss://gw:"), "url: {url}"); - assert!(url.contains("@valkey.prod.internal:6379"), "url: {url}"); - // The raw special chars must NOT appear unencoded in the userinfo. - assert!( - url.contains("p%40ss"), - "password '@' must be encoded: {url}" - ); - } - - /// Nit 1: a `username` with no `password` is ambiguous (would silently - /// connect as the default user). Reject it at config-load. - #[test] - fn username_without_password_is_rejected() { - let err = parse( - "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gateway\n", - ) - .unwrap_err(); - assert!(matches!(err, BuildError::Config(_)), "got {err:?}"); - } - - /// Nit 2: a full URL endpoint carries its own credentials; separate - /// `username`/`password` fields are ignored, so supplying both is rejected. - #[test] - fn url_endpoint_with_separate_credentials_is_rejected() { - let err = parse( - "kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\nusername: gw\npassword: s3cret\n", - ) - .unwrap_err(); - assert!(matches!(err, BuildError::Config(_)), "got {err:?}"); - } - - /// A lone `password` (no username) is the default-user AUTH case and stays - /// valid, producing `redis://:pass@host` (empty username). - #[test] - fn password_without_username_uses_default_user() { - let cfg = parse("kind: valkey\nendpoint: localhost:6379\npassword: s3cret\n").unwrap(); - let url = cfg.connection_url().unwrap(); - assert!( - url.starts_with("redis://:s3cret@localhost:6379"), - "url: {url}" - ); - } - - #[test] - fn missing_endpoint_is_config_error() { - let err = parse("kind: valkey\n").unwrap_err(); - assert!(matches!(err, BuildError::Config(_)), "got {err:?}"); - } - - #[test] - fn ipv6_loopback_without_tls_is_allowed() { - let cfg = parse("kind: valkey\nendpoint: \"[::1]:6379\"\n").unwrap(); - assert!(!cfg.tls_enabled()); - } - - #[test] - fn redact_endpoint_strips_userinfo() { - assert_eq!( - redact_endpoint("rediss://user:secret@host:6379"), - "rediss://***@host:6379" - ); - assert_eq!(redact_endpoint("host:6379"), "host:6379"); - } - - /// Credentials must never leak into the TLS-required error. - #[test] - fn tls_required_error_redacts_credentials() { - // rediss-less, non-localhost, with embedded creds, tls off → error. - let err = - parse("kind: valkey\nendpoint: redis://user:topsecret@prod.host:6379\n").unwrap_err(); - let msg = format!("{err}"); - assert!( - !msg.contains("topsecret"), - "error leaked credentials: {msg}" - ); - } -} diff --git a/builtins/session/valkey/src/connection.rs b/builtins/session/valkey/src/connection.rs deleted file mode 100644 index c748a307..00000000 --- a/builtins/session/valkey/src/connection.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Location: ./builtins/session/valkey/src/connection.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Internal connection layer: builds and holds the deadpool-redis -// pool for the Valkey backend. Kept private to this crate — it is NOT a -// public reusable API. When a second consumer (the planned OAuth token -// cache) is actually scheduled, extract a shared layer then -// (refactor-then-reuse), shaped by two real consumers. - -use deadpool_redis::{Config as PoolConfig, Pool, Runtime}; - -use crate::config::ValkeyConfig; -use crate::error::BuildError; - -/// Build the connection pool from validated config. The pool is created -/// lazily — `create_pool` does not dial Valkey, so a bad endpoint surfaces -/// on first use (where it correctly fails the request closed) rather than -/// blocking `load_config_yaml`. -pub(crate) fn build_pool(cfg: &ValkeyConfig) -> Result { - let url = cfg.connection_url()?; - let pool_cfg = PoolConfig::from_url(url); - // Note: the pool-create error is intentionally not interpolated with - // the URL — that string carries credentials. `connection_url()` has - // already validated the URL parses, so failures here are rare. - pool_cfg - .create_pool(Some(Runtime::Tokio1)) - .map_err(|e| BuildError::Pool(e.to_string())) -} diff --git a/builtins/session/valkey/src/error.rs b/builtins/session/valkey/src/error.rs deleted file mode 100644 index fef053c9..00000000 --- a/builtins/session/valkey/src/error.rs +++ /dev/null @@ -1,31 +0,0 @@ -// Location: ./builtins/session/valkey/src/error.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Construction-time errors for the Valkey session-store backend. These -// surface when the `global.apl.session_store` config block is malformed -// or the connection pool cannot be built — i.e. at `load_config_yaml` -// time, NOT on the request hot path. Request-time failures flow through -// `apl_cpex::SessionStoreError` (the trait's return type) so callers can -// fail closed. - -/// Error returned while building a `ValkeySessionStore` from config. -#[derive(Debug, thiserror::Error)] -pub enum BuildError { - /// The config block was structurally invalid (missing/!typed fields). - #[error("invalid valkey session_store config: {0}")] - Config(String), - - /// TLS is mandatory for any non-localhost endpoint: session - /// security labels must not transit a network segment in plaintext. - #[error( - "valkey session_store requires TLS for non-localhost endpoint '{0}' \ - — set `tls: true` or use a `rediss://` URL" - )] - TlsRequired(String), - - /// The connection pool could not be constructed (bad URL, etc.). - #[error("failed to build valkey connection pool: {0}")] - Pool(String), -} diff --git a/builtins/session/valkey/src/factory.rs b/builtins/session/valkey/src/factory.rs deleted file mode 100644 index 00a94f6d..00000000 --- a/builtins/session/valkey/src/factory.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Location: ./builtins/session/valkey/src/factory.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// `ValkeySessionStoreFactory` — the `SessionStoreFactory` that lets the -// apl-cpex visitor build a `ValkeySessionStore` from a -// `global.apl.session_store: { kind: valkey, ... }` block. Mirrors the -// PDP factories (CelPdpFactory, CedarDirectPdpFactory). - -use std::sync::Arc; - -use apl_cpex::{SessionStore, SessionStoreFactory}; - -use crate::config::ValkeyConfig; -use crate::store::ValkeySessionStore; - -/// The `kind:` discriminator this factory builds. Part of the public -/// surface — it is the string operators write in their config. -pub const KIND: &str = "valkey"; - -/// Factory the host registers via `AplOptions.session_store_factories`. -#[derive(Default)] -pub struct ValkeySessionStoreFactory; - -impl ValkeySessionStoreFactory { - pub fn new() -> Self { - Self - } -} - -impl SessionStoreFactory for ValkeySessionStoreFactory { - fn kind(&self) -> &str { - KIND - } - - fn build( - &self, - config: &serde_yaml::Value, - ) -> Result, Box> { - let cfg = ValkeyConfig::from_value(config)?; - let store = ValkeySessionStore::from_config(&cfg)?; - Ok(Arc::new(store)) - } -} diff --git a/builtins/session/valkey/src/lib.rs b/builtins/session/valkey/src/lib.rs deleted file mode 100644 index 21188966..00000000 --- a/builtins/session/valkey/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -// Location: ./builtins/session/valkey/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// cpex-session-valkey — a Valkey-backed `apl_cpex::SessionStore` for -// distributed, cross-restart persistence of session security labels. -// -// # Where this sits -// -// apl-cpex (SessionStore trait, SessionStoreFactory) -// ▲ -// │ implements -// cpex-session-valkey ──uses──▶ redis-rs + deadpool-redis (rustls) -// -// The host registers `ValkeySessionStoreFactory` via -// `AplOptions.session_store_factories`; a `global.apl.session_store: -// { kind: valkey, ... }` block then selects it during config load. When -// no such block is present, apl-cpex keeps its default in-process -// `MemorySessionStore`, so this crate is entirely opt-in. -// -// # Design invariants (carried from the requirements/plan) -// -// - Fail-closed: any backend error (unreachable, timeout, undecodable) -// becomes `SessionStoreError`; only a confirmed key-miss is empty. -// - Atomic union: `append_labels` is a single server-side `SADD`. -// - Primary-only: a single endpoint, no replica read-splitting. -// - TLS required off-localhost; `noeviction` is an operator runbook -// concern the client can only warn about. -// -// The connection layer is kept internal (no public reusable API): the -// planned OAuth token cache is the trigger to extract a shared layer -// later, shaped by two real consumers. - -mod config; -mod connection; -mod error; -mod factory; -mod store; - -pub use config::ValkeyConfig; -pub use error::BuildError; -pub use factory::{ValkeySessionStoreFactory, KIND}; -pub use store::ValkeySessionStore; diff --git a/builtins/session/valkey/src/store.rs b/builtins/session/valkey/src/store.rs deleted file mode 100644 index c9906bdc..00000000 --- a/builtins/session/valkey/src/store.rs +++ /dev/null @@ -1,167 +0,0 @@ -// Location: ./builtins/session/valkey/src/store.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// `ValkeySessionStore` — the Valkey-backed `SessionStore`. Labels live in -// a Redis SET per session so `append_labels` is a single atomic -// server-side union (`SADD`), never a client-side read-modify-write that -// would lose labels under concurrent cross-node appends. -// -// # Fail-closed mapping -// -// - `SMEMBERS` on a missing key returns an empty set → `Ok(empty)` -// (unknown session). It is NOT an error. -// - connection/timeout/protocol/decode failures → `Err(Backend)` so the -// caller fails the request closed. -// -// # Sliding TTL -// -// `append_labels` issues `SADD` + `EXPIRE` in one atomic pipeline. -// `load_labels` refreshes the TTL fail-open: the read already succeeded, -// so a refresh failure is alarmed but the labels are still returned. - -use std::fmt::Write as _; -use std::time::Duration; - -use apl_cpex::{SessionStore, SessionStoreError}; -use async_trait::async_trait; -use deadpool_redis::{Connection, Pool}; -use redis::AsyncCommands; -use sha2::{Digest, Sha256}; - -use crate::config::ValkeyConfig; -use crate::connection::build_pool; -use crate::error::BuildError; - -/// Valkey-backed session label store. -pub struct ValkeySessionStore { - pool: Pool, - key_prefix: String, - ttl_seconds: Option, - connect_timeout: Duration, - command_timeout: Duration, -} - -impl ValkeySessionStore { - /// Build from validated config. The pool is created lazily, so this - /// does not dial Valkey — connection failures surface on first use - /// and correctly fail the request closed. - pub fn from_config(cfg: &ValkeyConfig) -> Result { - Ok(Self { - pool: build_pool(cfg)?, - key_prefix: cfg.key_prefix.clone(), - ttl_seconds: cfg.ttl_seconds, - connect_timeout: Duration::from_millis(cfg.connect_timeout_ms), - command_timeout: Duration::from_millis(cfg.command_timeout_ms), - }) - } - - /// Key schema: `:`. The full-width - /// digest keeps the Valkey keyspace collision-free and removes raw - /// session ids from it. - fn key(&self, session_id: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(session_id.as_bytes()); - let digest = hasher.finalize(); - let mut hex = String::with_capacity(digest.len() * 2); - for byte in digest { - let _ = write!(hex, "{byte:02x}"); - } - format!("{}:{}", self.key_prefix, hex) - } - - /// Acquire a pooled connection, bounded by the connect timeout (the - /// fail-fast knob for a dead/slow endpoint, distinct from the - /// per-command timeout applied to SMEMBERS/SADD below). - async fn conn(&self) -> Result { - match tokio::time::timeout(self.connect_timeout, self.pool.get()).await { - Ok(Ok(conn)) => Ok(conn), - Ok(Err(e)) => Err(backend(e)), - Err(_) => Err(SessionStoreError::Backend( - "valkey connection acquire timed out".to_string(), - )), - } - } -} - -/// Map any backend failure to the fail-closed `SessionStoreError`. -fn backend(e: impl std::fmt::Display) -> SessionStoreError { - SessionStoreError::Backend(e.to_string()) -} - -#[async_trait] -impl SessionStore for ValkeySessionStore { - async fn load_labels(&self, session_id: &str) -> Result, SessionStoreError> { - let key = self.key(session_id); - let mut conn = self.conn().await?; - - // SMEMBERS on a missing key returns an empty set (Ok), so an - // unknown session naturally maps to Ok(empty). Only a real - // backend failure becomes Err. - let labels: Vec = - match tokio::time::timeout(self.command_timeout, conn.smembers(&key)).await { - Ok(res) => res.map_err(backend)?, - Err(_) => { - return Err(SessionStoreError::Backend( - "valkey SMEMBERS timed out".to_string(), - )) - }, - }; - - // Sliding-TTL refresh is fail-open for the read: the labels were - // read successfully, so a refresh failure is alarmed, not failed - // closed. A persistently-failing refresh risks silent key - // expiry across requests — see the operator runbook. - if let Some(ttl) = self.ttl_seconds { - let refresh: Result = - match tokio::time::timeout(self.command_timeout, conn.expire(&key, ttl as i64)) - .await - { - Ok(res) => res, - Err(_) => Ok(false), // treat timeout as a failed refresh - }; - if let Err(e) = refresh { - tracing::warn!( - alarm = "session_store_ttl_refresh_failed", - error = %e, - "valkey TTL refresh on load failed; returning read labels (fail-open)" - ); - } - } - - Ok(labels) - } - - async fn append_labels( - &self, - session_id: &str, - labels: &[String], - ) -> Result<(), SessionStoreError> { - if labels.is_empty() { - return Ok(()); - } - let key = self.key(session_id); - let mut conn = self.conn().await?; - - // Atomic server-side union + optional TTL refresh in one round - // trip (MULTI/EXEC). SADD is a commutative merge, so concurrent - // cross-node appends never lose labels. - let mut pipe = redis::pipe(); - pipe.atomic(); - pipe.sadd(&key, labels).ignore(); - if let Some(ttl) = self.ttl_seconds { - pipe.expire(&key, ttl as i64).ignore(); - } - - match tokio::time::timeout(self.command_timeout, pipe.query_async::<()>(&mut conn)).await { - Ok(res) => res.map_err(backend)?, - Err(_) => { - return Err(SessionStoreError::Backend( - "valkey append (SADD+EXPIRE) timed out".to_string(), - )) - }, - } - Ok(()) - } -} diff --git a/builtins/session/valkey/tests/valkey_store_integration.rs b/builtins/session/valkey/tests/valkey_store_integration.rs deleted file mode 100644 index 798d7c9c..00000000 --- a/builtins/session/valkey/tests/valkey_store_integration.rs +++ /dev/null @@ -1,222 +0,0 @@ -// Location: ./builtins/session/valkey/tests/valkey_store_integration.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// Integration tests for ValkeySessionStore against a real `valkey/valkey` -// container (testcontainers). These are `#[ignore]`d by default so unit -// runs don't require Docker; run them with: -// -// cargo test -p cpex-session-valkey -- --ignored -// -// Skip discipline (learning from PR #67's silent no-op tests): -// - If `VALKEY_TEST_URL` is set, run against that endpoint (a CI service -// container or a locally-run `valkey/valkey`) — no testcontainers. -// - Else start a testcontainers `valkey/valkey`. If that can't start AND -// `REQUIRE_VALKEY_TESTS=1` is set (CI), that is a hard failure (panic) -// — the test genuinely ran. -// - Otherwise (local, no Docker) the helper prints a loud SKIPPED line -// and the test returns without asserting. The visible line is what -// stops a silent green. - -use apl_cpex::{SessionStore, SessionStoreError}; -use cpex_session_valkey::{ValkeyConfig, ValkeySessionStore}; -use sha2::{Digest, Sha256}; -use testcontainers_modules::testcontainers::runners::AsyncRunner; -use testcontainers_modules::testcontainers::ContainerAsync; -use testcontainers_modules::valkey::{Valkey, VALKEY_PORT}; - -/// A Valkey endpoint to test against, plus the container handle when one -/// was started (kept alive for the test's duration). -struct Target { - url: String, - _container: Option>, -} - -/// Resolve a Valkey target, or skip loudly when none is available. -/// Returns `None` to signal the caller should return early (skip). -async fn valkey_target() -> Option { - if let Ok(url) = std::env::var("VALKEY_TEST_URL") { - return Some(Target { - url, - _container: None, - }); - } - match Valkey::default().start().await { - Ok(node) => { - let host = node.get_host().await.expect("container host"); - let port = node - .get_host_port_ipv4(VALKEY_PORT) - .await - .expect("container port"); - Some(Target { - url: format!("redis://{host}:{port}"), - _container: Some(node), - }) - }, - Err(e) => { - if std::env::var("REQUIRE_VALKEY_TESTS").as_deref() == Ok("1") { - panic!("REQUIRE_VALKEY_TESTS=1 but no Valkey available: {e} (set VALKEY_TEST_URL or start Docker)"); - } - eprintln!( - "SKIPPED: no Valkey available ({e}); set VALKEY_TEST_URL or REQUIRE_VALKEY_TESTS=1" - ); - None - }, - } -} - -/// Build a store pointed at the target. -fn store_for(target: &Target, ttl_seconds: Option) -> ValkeySessionStore { - let mut yaml = format!("kind: valkey\nendpoint: {}\n", target.url); - if let Some(ttl) = ttl_seconds { - yaml.push_str(&format!("ttl_seconds: {ttl}\n")); - } - let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); - let cfg = ValkeyConfig::from_value(&value).expect("valid config"); - ValkeySessionStore::from_config(&cfg).expect("build store") -} - -/// Raw connection for white-box assertions (TTL, seeding a wrong-typed key). -async fn raw_conn(target: &Target) -> redis::aio::MultiplexedConnection { - redis::Client::open(target.url.clone()) - .unwrap() - .get_multiplexed_async_connection() - .await - .unwrap() -} - -/// Replicate the store's key schema so white-box tests can target the -/// exact key (documents the schema as a side effect). -fn store_key(session_id: &str) -> String { - let digest = Sha256::digest(session_id.as_bytes()); - let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); - format!("taint:v1:{hex}") -} - -/// Concurrent appends from two "nodes" (separate store -/// instances against one Valkey) union without loss; a third reader sees -/// the full set. -#[tokio::test] -#[ignore] -async fn cross_node_concurrent_append_unions() { - let Some(target) = valkey_target().await else { - return; - }; - let node_a = store_for(&target, None); - let node_b = store_for(&target, None); - let sid = "sess-union"; - - let labels_a = vec!["PII".to_string()]; - let labels_b = vec!["INTERNAL".to_string()]; - let (ra, rb) = tokio::join!( - node_a.append_labels(sid, &labels_a), - node_b.append_labels(sid, &labels_b), - ); - ra.expect("node A append"); - rb.expect("node B append"); - - let reader = store_for(&target, None); - let mut labels = reader.load_labels(sid).await.expect("load"); - labels.sort(); - assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]); -} - -/// An unknown session is a confirmed key-miss → Ok(empty), not Err. -#[tokio::test] -#[ignore] -async fn unknown_session_returns_empty_ok() { - let Some(target) = valkey_target().await else { - return; - }; - let store = store_for(&target, None); - let labels = store - .load_labels("never-written") - .await - .expect("unknown session must be Ok(empty), not Err"); - assert!(labels.is_empty()); -} - -/// A reachable but undecodable reply (key holds a string, not a SET) -/// fails closed (Err) rather than returning Ok(empty). -#[tokio::test] -#[ignore] -async fn wrongtype_reply_fails_closed() { - let Some(target) = valkey_target().await else { - return; - }; - let store = store_for(&target, None); - - // Seed the exact key as a plain string so SMEMBERS returns WRONGTYPE. - let mut conn = raw_conn(&target).await; - let sid = "sess-wrongtype"; - let _: () = redis::cmd("SET") - .arg(store_key(sid)) - .arg("not-a-set") - .query_async(&mut conn) - .await - .unwrap(); - - let result = store.load_labels(sid).await; - assert!( - matches!(result, Err(SessionStoreError::Backend(_))), - "WRONGTYPE must fail closed, got {result:?}" - ); -} - -/// An unreachable endpoint fails closed quickly (bounded by the -/// command timeout). No container needed, but kept with the suite. -#[tokio::test] -#[ignore] -async fn unreachable_endpoint_fails_closed() { - // Port 1 is not listening; localhost so TLS is not required. - let value: serde_yaml::Value = - serde_yaml::from_str("kind: valkey\nendpoint: 127.0.0.1:1\ncommand_timeout_ms: 300\n") - .unwrap(); - let cfg = ValkeyConfig::from_value(&value).unwrap(); - let store = ValkeySessionStore::from_config(&cfg).unwrap(); - - let result = store.load_labels("sess-x").await; - assert!( - matches!(result, Err(SessionStoreError::Backend(_))), - "unreachable endpoint must fail closed, got {result:?}" - ); -} - -/// A configured TTL is set on append and refreshed on load. -#[tokio::test] -#[ignore] -async fn ttl_set_on_append_and_refreshed_on_load() { - let Some(target) = valkey_target().await else { - return; - }; - let store = store_for(&target, Some(100)); - let sid = "sess-ttl"; - store - .append_labels(sid, &["PII".to_string()]) - .await - .expect("append"); - - let mut conn = raw_conn(&target).await; - let ttl_after_append: i64 = redis::cmd("TTL") - .arg(store_key(sid)) - .query_async(&mut conn) - .await - .unwrap(); - assert!( - ttl_after_append > 0 && ttl_after_append <= 100, - "append should set a positive TTL, got {ttl_after_append}" - ); - - // A load refreshes the sliding TTL back toward the configured window. - let _ = store.load_labels(sid).await.expect("load"); - let ttl_after_load: i64 = redis::cmd("TTL") - .arg(store_key(sid)) - .query_async(&mut conn) - .await - .unwrap(); - assert!( - ttl_after_load > 0, - "load should keep/refresh a positive TTL, got {ttl_after_load}" - ); -} diff --git a/crates/README.md b/crates/README.md index 2fd55606..aba39c33 100644 --- a/crates/README.md +++ b/crates/README.md @@ -1,212 +1,54 @@ -# CPEX Rust Core +# CPEX Rust crates -Phase 1a of the CPEX Rust plugin runtime. Provides the core types, 5-phase executor, and plugin manager for the ContextForge Plugin Extensibility Framework. +CPEX runs on the praxis-policy engine (PPE), consumed from crates.io at an +exact version pin. The engine, its APL layer and the bundled builtins are PPE +crates; this directory holds only what CPEX adds on top. -## Status +- `cpex`: host facade. Re-exports the public surface of `praxis-policy` + (`PolicyEngine`, `install_builtins`, `prelude`, `AplOptions`, the builtin + factories, `HyperTransport`), built with every stable builtin and the + bundled hyper HTTP transport. The `python-host` feature adds the Python + plugin host. +- `cpex-hosts-python`: runs existing Python plugins out-of-process, each in + its own cached virtualenv, under the `isolated_venv` kind + ([README](cpex-hosts-python/README.md)). +- `cpex-ffi`: C ABI over the engine, used by the Go package and the Go demo. -Phase 1a — core runtime functional, no language bindings yet. +The Biscuit delegator lives in `builtins/plugins/delegator-biscuit`, and the +PyO3 bindings in `bindings/python`. -- `cpex-core`: Plugin trait, typed hooks, 5-phase executor, plugin manager -- `cpex-sdk`: Lean re-exports for plugin authors -- `cpex-hosts-python`: Runs existing Python plugins out-of-process, each in its - own cached virtualenv, under the `isolated_venv` kind - ([README](cpex-hosts-python/README.md)) +## Build and test -## Prerequisites - -### Install Rust - -If you don't have Rust installed: - -```bash -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -``` - -Follow the prompts, then restart your shell or run: - -```bash -source $HOME/.cargo/env -``` - -Verify the installation: - -```bash -rustc --version # should be 1.96 or newer (see rust-toolchain.toml) -cargo --version -``` - -### Update an existing installation +Requires the toolchain pinned in `rust-toolchain.toml`. From the repository +root: ```bash -rustup update stable +cargo build -p cpex +cargo test -p cpex ``` -### Build and test - -From the repository root: - -```bash -# Check that everything compiles -cargo check -p cpex-core -p cpex-sdk - -# Run all tests -cargo test -p cpex-core -p cpex-sdk -``` - -## What It Does - -A typed, 5-phase plugin execution framework where: - -- **Hooks have typed payloads** — no JSON parsing for native Rust plugins -- **Extensions are separate from payloads** — capability-filtered per plugin, modified independently -- **The framework never clones payloads** — handlers receive borrows, clone only when modifying -- **Plugin configs are trusted** — `PluginRef` holds config from the loader, not from the plugin -- **Two invoke paths** — `invoke::()` (typed, Rust) and `invoke_by_name()` (dynamic, Python/Go) - -## Quick Example +## Quick example ```rust use std::sync::Arc; -use async_trait::async_trait; -use cpex_core::context::{GlobalContext, PluginContext}; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::hooks::payload::{Extensions, FilteredExtensions}; -use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig, PluginMode, OnError}; - -// 1. Define a payload -#[derive(Debug, Clone)] -struct ToolCallPayload { - tool_name: String, - include_ssn: bool, -} -cpex_core::impl_plugin_payload!(ToolCallPayload); - -// 2. Define a hook type -struct ToolPreInvoke; -impl HookTypeDef for ToolPreInvoke { - type Payload = ToolCallPayload; - type Result = PluginResult; - const NAME: &'static str = "tool_pre_invoke"; -} - -// 3. Write a plugin -struct SsnGuard { config: PluginConfig } - -#[async_trait] -impl Plugin for SsnGuard { - fn config(&self) -> &PluginConfig { &self.config } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } -} - -impl HookHandler for SsnGuard { - fn handle( - &self, - payload: &ToolCallPayload, // borrow — zero cost - _extensions: &FilteredExtensions, - _ctx: &PluginContext, - ) -> PluginResult { - if payload.include_ssn { - PluginResult::deny(PluginViolation::new("ssn_denied", "Requires permission")) - } else { - PluginResult::allow() - } - } -} - -// 4. Register and invoke -async fn run() { - let mut manager = PluginManager::default(); - - let config = PluginConfig { - name: "ssn-guard".into(), - kind: "builtin".into(), - hooks: vec!["tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - ..Default::default() - }; - - let plugin = Arc::new(SsnGuard { config: config.clone() }); - manager.register_handler::(plugin, config).unwrap(); - manager.initialize().await.unwrap(); - - let payload = ToolCallPayload { - tool_name: "get_compensation".into(), - include_ssn: true, - }; - - let result = manager - .invoke::(payload, Extensions::default(), &GlobalContext::new("req-1")) - .await; - - assert!(!result.allowed); // denied — SSN access blocked -} -``` - -## Crate Structure - -``` -crates/ -├── cpex-core/src/ -│ ├── lib.rs — module declarations -│ ├── plugin.rs — Plugin trait (lifecycle), PluginConfig, PluginMode, OnError, PluginCondition -│ ├── error.rs — PluginError, PluginViolation -│ ├── context.rs — GlobalContext, PluginContext -│ ├── hooks/ -│ │ ├── payload.rs — PluginPayload trait (object-safe), Extensions, FilteredExtensions -│ │ ├── trait_def.rs — HookTypeDef, HookHandler, PluginResult -│ │ ├── adapter.rs — TypedHandlerAdapter (bridges typed handlers to type-erased dispatch) -│ │ ├── macros.rs — define_hook! macro -│ │ └── types.rs — HookType (string wrapper), hook_names, cmf_hook_names -│ ├── registry.rs — PluginRef (trusted config), PluginRegistry, AnyHookHandler, HookEntry -│ ├── executor.rs — 5-phase engine, PipelineResult, ErasedResultFields -│ ├── manager.rs — PluginManager (register_handler, invoke, invoke_by_name, lifecycle) -│ └── config.rs — (stub — unified YAML parsing, Phase 2) -└── cpex-sdk/src/ - └── lib.rs — lean re-exports for plugin authors -``` - -## 5-Phase Execution Model - -``` -SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET -``` - -| Phase | Can Block? | Can Modify? | Execution | -|-------|------------|-------------|-----------| -| Sequential | Yes | Yes (clone) | Serial, chained | -| Transform | No | Yes (clone) | Serial, chained | -| Audit | No | No | Serial | -| Concurrent | Yes | No | Parallel | -| FireAndForget | No | No | Background | - -All handlers receive `&Payload` (borrow). The framework holds ownership. Modified payloads are returned in `PluginResult::modified_payload` and replace the current payload in the pipeline. - -## Key Design Decisions - -- **PluginRef trust model** — configs come from the config loader, not from `plugin.config()`. Prevents plugins from tampering with their own priority, mode, or capabilities. -- **Borrow-based handlers** — handlers receive `&Payload`, not owned. Framework never clones. Plugins clone only when modifying. Enforced by Rust's borrow checker at compile time. -- **Single `invoke()` path** — one method on `AnyHookHandler`, not separate `invoke_owned`/`invoke_ref`. Simpler API, same behavior. -- **`PluginPayload` trait** — object-safe base for all payloads. `Box` instead of `Box` — type errors caught at compile time. -- **Extensions separate from payload** — capability-filtered per plugin, modified independently. Extension-only changes don't clone the payload. - -## Tests - -```bash -cargo test -p cpex-core -p cpex-sdk -``` - -27 unit tests + 6 doc tests covering: registration, priority ordering, trusted config tamper protection, 5-phase execution, allow/deny/modify results, lifecycle management, typed and dynamic invoke paths. - -## What's Next - -- **Phase 1b**: `cpex-ffi` + Go bindings (first language binding) -- **Phase 1c**: Conformance test corpus (YAML scenarios, Python + Rust) -- **Phase 2**: Unified YAML config parsing -- **Phase 3**: Full CMF extension types (MonotonicSet, Guarded, MetaExtension, etc.) - -See [CPEX Rust Core Proposal](../docs/cpex-rust-core-proposal.md) for the full roadmap. +use cpex::PolicyEngine; + +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +cpex::install_default_http_transport(&engine); +engine.load_config_yaml(yaml)?; +engine.initialize().await?; +``` + +`install_default_http_transport` refuses private and loopback destinations. +For a local IdP during development, install +`HyperTransport::new().with_allow_private_destinations()` with +`engine.set_http_transport(...)` instead; it opens every private address to +every outbound builtin, so keep it out of production. + +Hook names, config keys and the plugin API follow PPE. See the PPE crate docs +for `praxis-policy` and `praxis-policy-core` for the full reference, the +[crate map](https://contextforge-org.github.io/cpex/docs/crates/) for what each +crate holds, and the +[migration guide](https://contextforge-org.github.io/cpex/docs/migration-0.3/) +for changes from 0.2.x. diff --git a/crates/apl-cmf/Cargo.toml b/crates/apl-cmf/Cargo.toml deleted file mode 100644 index 3340028e..00000000 --- a/crates/apl-cmf/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -# Location: ./crates/apl-cmf/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# apl-cmf — bridge from cpex-core typed extensions into apl-core's -# AttributeBag. The "where the policy vocabulary comes from" crate. - -[package] -name = "apl-cmf" -description = "APL ↔ CPEX bridge — extension → AttributeBag mapping" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -apl-core = { workspace = true } -cpex-core = { workspace = true } -serde_json = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -async-trait = { workspace = true } - -[lints] -workspace = true diff --git a/crates/apl-cmf/src/agent.rs b/crates/apl-cmf/src/agent.rs deleted file mode 100644 index 7961f5b0..00000000 --- a/crates/apl-cmf/src/agent.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Location: ./crates/apl-cmf/src/agent.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// AgentExtension → AttributeBag. -// -// Namespace: -// agent.input : String -// agent.session_id : String -// agent.conversation_id : String -// agent.turn : Int -// agent.agent_id : String -// agent.parent_agent_id : String -// agent.conversation.summary : String -// agent.conversation.topics : StringSet - -use apl_core::AttributeBag; -use cpex_core::extensions::AgentExtension; -use std::collections::HashSet; - -pub fn extract_agent(agent: &AgentExtension, bag: &mut AttributeBag) { - if let Some(v) = &agent.input { - bag.set("agent.input", v.clone()); - } - if let Some(v) = &agent.session_id { - bag.set("agent.session_id", v.clone()); - } - if let Some(v) = &agent.conversation_id { - bag.set("agent.conversation_id", v.clone()); - } - if let Some(v) = agent.turn { - bag.set("agent.turn", v as i64); - } - if let Some(v) = &agent.agent_id { - bag.set("agent.agent_id", v.clone()); - } - if let Some(v) = &agent.parent_agent_id { - bag.set("agent.parent_agent_id", v.clone()); - } - if let Some(conv) = &agent.conversation { - if let Some(s) = &conv.summary { - bag.set("agent.conversation.summary", s.clone()); - } - if !conv.topics.is_empty() { - let topics: HashSet = conv.topics.iter().cloned().collect(); - bag.set("agent.conversation.topics", topics); - } - // `history: Vec` is deliberately not flattened — too unstructured. - // Policies wanting conversation history should call a plugin. - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::agent::ConversationContext; - - #[test] - fn populates_present_fields_only() { - let agent = AgentExtension { - session_id: Some("sess-1".into()), - conversation_id: Some("conv-9".into()), - turn: Some(3), - agent_id: Some("hr-agent".into()), - parent_agent_id: None, - conversation: Some(ConversationContext { - summary: Some("hr inquiry".into()), - topics: vec!["payroll".into(), "ssn".into()], - ..Default::default() - }), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_agent(&agent, &mut bag); - assert_eq!(bag.get_string("agent.session_id"), Some("sess-1")); - assert_eq!(bag.get_int("agent.turn"), Some(3)); - assert_eq!( - bag.get_string("agent.conversation.summary"), - Some("hr inquiry") - ); - assert!(bag.set_contains("agent.conversation.topics", "payroll")); - assert!(!bag.contains("agent.parent_agent_id")); - } -} diff --git a/crates/apl-cmf/src/capability_namespaces.rs b/crates/apl-cmf/src/capability_namespaces.rs deleted file mode 100644 index 8c00da9d..00000000 --- a/crates/apl-cmf/src/capability_namespaces.rs +++ /dev/null @@ -1,307 +0,0 @@ -// Location: ./crates/apl-cmf/src/capability_namespaces.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Capability → bag-namespace mapping for operator visibility. -// -// cpex-core's `filter_extensions(&ext, &caps)` decides which -// `Extensions` slots a plugin sees based on its declared -// `capabilities:` list. The CMF extractors then flatten those slots -// into bag attributes under well-known prefixes. This module is the -// bridge: given a capability name, return the bag-attribute prefixes -// it unlocks. Lets operators answer "what bag keys does this plugin -// see?" without reading source. -// -// # Scope -// -// Covers the `read_*` capabilities — those map to bag namespaces -// because the corresponding Extensions slots become bag attributes -// after extraction. Write capabilities (`append_labels`, -// `append_delegation`, `write_headers`) gate WRITE tokens, not -// readable state, so they don't appear here. -// -// # Source of truth -// -// All hard-coded strings — both capability names and bag-attribute -// prefixes — live in [`crate::constants`]. The table below -// references the constants rather than inlining strings, so a typo -// surfaces at compile time and the constants file is the single -// place to update names. - -use std::collections::HashSet; - -use crate::constants::*; - -/// Prefix mapping entry. `prefixes` lists the bag-attribute -/// namespace roots this capability unlocks. A prefix ending in `.` -/// means "any key starting with that root" (e.g. `role.` matches -/// `role.hr`). A prefix without a trailing `.` means an exact-match -/// key (e.g. `authenticated`). -struct CapabilityEntry { - name: &'static str, - prefixes: &'static [&'static str], -} - -/// The mapping table — single source of truth for which bag -/// namespaces a capability unlocks. Keep in sync with cpex-core's -/// `filter_extensions` rules and the per-extension extractor -/// modules (`security.rs`, `delegation.rs`, etc.). -const TABLE: &[CapabilityEntry] = &[ - CapabilityEntry { - name: CAP_READ_SUBJECT, - // `read_subject` exposes id + type only; `authenticated` is - // derived from those being present. - prefixes: &[BAG_SUBJECT_ID, BAG_SUBJECT_TYPE, BAG_AUTHENTICATED], - }, - CapabilityEntry { - name: CAP_READ_ROLES, - // Implies the read_subject baseline + role.* prefix. - prefixes: &[ - BAG_ROLE_PREFIX, - BAG_SUBJECT_ID, - BAG_SUBJECT_TYPE, - BAG_AUTHENTICATED, - ], - }, - CapabilityEntry { - name: CAP_READ_PERMISSIONS, - prefixes: &[ - BAG_PERM_PREFIX, - BAG_SUBJECT_ID, - BAG_SUBJECT_TYPE, - BAG_AUTHENTICATED, - ], - }, - CapabilityEntry { - name: CAP_READ_TEAMS, - prefixes: &[ - BAG_SUBJECT_TEAMS, - BAG_SUBJECT_ID, - BAG_SUBJECT_TYPE, - BAG_AUTHENTICATED, - ], - }, - CapabilityEntry { - name: CAP_READ_CLAIMS, - prefixes: &[ - BAG_CLAIM_PREFIX, - BAG_SUBJECT_ID, - BAG_SUBJECT_TYPE, - BAG_AUTHENTICATED, - ], - }, - CapabilityEntry { - // Labels are not extracted into discrete bag keys today — - // they live on `Extensions.security.labels` and plugins - // read them directly. APL's BagBuilder doesn't materialize - // a bag-readable label namespace yet; if it does, add the - // prefix constant + reference here. - name: CAP_READ_LABELS, - prefixes: &[], - }, - CapabilityEntry { - name: CAP_READ_CLIENT, - prefixes: &[BAG_CLIENT_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_WORKLOAD, - // Exposes both inbound caller workload AND this-host workload. - prefixes: &[BAG_WORKLOAD_PREFIX, BAG_CALLER_WORKLOAD_PREFIX], - }, - CapabilityEntry { - // Gates `Extensions.raw_credentials.inbound_tokens` — those - // tokens flow through plugin payloads (IdentityPayload, - // DelegationPayload), not into the bag. - name: CAP_READ_INBOUND_CREDENTIALS, - prefixes: &[], - }, - CapabilityEntry { - name: CAP_READ_DELEGATED_TOKENS, - prefixes: &[], - }, - CapabilityEntry { - name: CAP_READ_DELEGATION, - prefixes: &[BAG_DELEGATION_PREFIX, BAG_DELEGATED], - }, - CapabilityEntry { - name: CAP_READ_AGENT, - prefixes: &[BAG_AGENT_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_META, - prefixes: &[BAG_META_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_REQUEST, - prefixes: &[BAG_REQUEST_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_HEADERS, - prefixes: &[ - BAG_HTTP_REQUEST_HEADERS_PREFIX, - BAG_HTTP_RESPONSE_HEADERS_PREFIX, - // The request line rides the same capability as headers. - BAG_HTTP_METHOD, - BAG_HTTP_PATH, - BAG_HTTP_HOST, - BAG_HTTP_SCHEME, - ], - }, - CapabilityEntry { - name: CAP_READ_LLM, - prefixes: &[BAG_LLM_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_MCP, - prefixes: &[BAG_MCP_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_COMPLETION, - prefixes: &[BAG_COMPLETION_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_PROVENANCE, - prefixes: &[BAG_PROVENANCE_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_FRAMEWORK, - prefixes: &[BAG_FRAMEWORK_PREFIX], - }, - CapabilityEntry { - name: CAP_READ_CUSTOM, - prefixes: &[BAG_CUSTOM_PREFIX], - }, -]; - -/// Bag-attribute prefixes a single capability unlocks. Returns an -/// empty slice for capabilities that don't expose bag-readable -/// state (write capabilities, or read capabilities for slots that -/// aren't extracted into the bag). Unknown capability names also -/// return empty — operators may declare custom caps the framework -/// doesn't recognize, and we don't want to imply they unlock -/// nothing in some "official" sense. -/// -/// A prefix ending in `.` matches any bag key starting with it -/// (e.g. `"role."` matches `"role.hr"`, `"role.admin"`). -/// A prefix without a trailing `.` matches the exact bag key -/// (e.g. `"authenticated"` matches only that bag key). -pub fn capability_namespaces(cap: &str) -> &'static [&'static str] { - TABLE - .iter() - .find(|e| e.name == cap) - .map(|e| e.prefixes) - .unwrap_or(&[]) -} - -/// Union of all bag-attribute prefixes unlocked by a set of -/// capabilities. Useful for operators answering "what can this -/// plugin see in the bag, given its declared caps?" without walking -/// the table per cap themselves. -pub fn unlocked_bag_prefixes(caps: &[String]) -> HashSet<&'static str> { - caps.iter() - .flat_map(|c| capability_namespaces(c).iter().copied()) - .collect() -} - -/// Every capability the framework recognizes for bag-namespace -/// purposes (excludes write caps and unknown ones). Useful for -/// completion / docs / config validation. -pub fn known_read_capabilities() -> impl Iterator { - TABLE.iter().map(|e| e.name) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn read_subject_exposes_id_type_authenticated() { - let prefixes = capability_namespaces(CAP_READ_SUBJECT); - assert!(prefixes.contains(&BAG_SUBJECT_ID)); - assert!(prefixes.contains(&BAG_SUBJECT_TYPE)); - assert!(prefixes.contains(&BAG_AUTHENTICATED)); - } - - #[test] - fn read_roles_implies_subject_baseline_plus_role_prefix() { - let prefixes = capability_namespaces(CAP_READ_ROLES); - assert!(prefixes.contains(&BAG_ROLE_PREFIX)); - // Implied subject baseline. - assert!(prefixes.contains(&BAG_SUBJECT_ID)); - assert!(prefixes.contains(&BAG_AUTHENTICATED)); - } - - #[test] - fn read_delegation_exposes_delegation_namespace_and_delegated_flag() { - let prefixes = capability_namespaces(CAP_READ_DELEGATION); - assert!(prefixes.contains(&BAG_DELEGATION_PREFIX)); - assert!(prefixes.contains(&BAG_DELEGATED)); - } - - #[test] - fn read_headers_exposes_both_request_and_response_header_namespaces() { - let prefixes = capability_namespaces(CAP_READ_HEADERS); - assert!(prefixes.contains(&BAG_HTTP_REQUEST_HEADERS_PREFIX)); - assert!(prefixes.contains(&BAG_HTTP_RESPONSE_HEADERS_PREFIX)); - } - - #[test] - fn unknown_capability_returns_empty() { - assert!(capability_namespaces("read_nonsense").is_empty()); - } - - #[test] - fn write_capability_returns_empty() { - assert!(capability_namespaces(CAP_APPEND_LABELS).is_empty()); - assert!(capability_namespaces(CAP_APPEND_DELEGATION).is_empty()); - assert!(capability_namespaces(CAP_WRITE_HEADERS).is_empty()); - } - - #[test] - fn payload_only_credential_caps_return_empty() { - // These caps gate Extensions slots that flow through plugin - // payloads, not bag attributes. - assert!(capability_namespaces(CAP_READ_INBOUND_CREDENTIALS).is_empty()); - assert!(capability_namespaces(CAP_READ_DELEGATED_TOKENS).is_empty()); - // read_labels too — labels aren't materialized into bag keys. - assert!(capability_namespaces(CAP_READ_LABELS).is_empty()); - } - - #[test] - fn unlocked_bag_prefixes_unions_multiple_caps() { - let caps = vec![CAP_READ_SUBJECT.to_string(), CAP_READ_ROLES.to_string()]; - let union = unlocked_bag_prefixes(&caps); - assert!(union.contains(BAG_SUBJECT_ID)); - assert!(union.contains(BAG_ROLE_PREFIX)); - // Deduplicates the shared subject baseline — only ONE - // entry for the common BAG_SUBJECT_ID even though both - // caps include it. - let baseline_count = union.iter().filter(|p| **p == BAG_SUBJECT_ID).count(); - assert_eq!(baseline_count, 1); - } - - #[test] - fn unlocked_bag_prefixes_skips_unknown_caps() { - let caps = vec![CAP_READ_SUBJECT.to_string(), "read_made_up".to_string()]; - let union = unlocked_bag_prefixes(&caps); - assert!(union.contains(BAG_SUBJECT_ID)); - // Unknown cap contributes nothing — no panic, no surprise key. - // read_subject contributes 3 entries; that's the total. - assert_eq!(union.len(), 3); - } - - #[test] - fn known_read_capabilities_returns_every_table_entry() { - let count = known_read_capabilities().count(); - // Sanity: substantial but bounded — table bloat would be - // a maintenance signal. - assert!(count > 10, "expected >10 known caps, got {count}"); - assert!(count < 50, "table grew unexpectedly to {count} entries"); - // Spot-check canonical names are present. - let names: HashSet<&str> = known_read_capabilities().collect(); - assert!(names.contains(CAP_READ_SUBJECT)); - assert!(names.contains(CAP_READ_META)); - assert!(names.contains(CAP_READ_DELEGATION)); - } -} diff --git a/crates/apl-cmf/src/completion.rs b/crates/apl-cmf/src/completion.rs deleted file mode 100644 index 5dc0c876..00000000 --- a/crates/apl-cmf/src/completion.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Location: ./crates/apl-cmf/src/completion.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CompletionExtension → AttributeBag. -// -// Namespace: -// completion.stop_reason : String (snake_case: "end" | "return" | "call" | "max_tokens" | "stop_sequence") -// completion.model : String -// completion.raw_format : String -// completion.created_at : String -// completion.latency_ms : Int -// completion.tokens.input : Int -// completion.tokens.output : Int -// completion.tokens.total : Int - -use apl_core::AttributeBag; -use cpex_core::extensions::{CompletionExtension, StopReason}; - -pub fn extract_completion(c: &CompletionExtension, bag: &mut AttributeBag) { - if let Some(sr) = c.stop_reason { - bag.set("completion.stop_reason", stop_reason_str(sr)); - } - if let Some(tu) = &c.tokens { - bag.set("completion.tokens.input", tu.input_tokens as i64); - bag.set("completion.tokens.output", tu.output_tokens as i64); - bag.set("completion.tokens.total", tu.total_tokens as i64); - } - if let Some(v) = &c.model { - bag.set("completion.model", v.clone()); - } - if let Some(v) = &c.raw_format { - bag.set("completion.raw_format", v.clone()); - } - if let Some(v) = &c.created_at { - bag.set("completion.created_at", v.clone()); - } - if let Some(ms) = c.latency_ms { - bag.set("completion.latency_ms", ms as i64); - } -} - -fn stop_reason_str(sr: StopReason) -> &'static str { - match sr { - StopReason::End => "end", - StopReason::Return => "return", - StopReason::Call => "call", - StopReason::MaxTokens => "max_tokens", - StopReason::StopSequence => "stop_sequence", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::completion::TokenUsage; - - #[test] - fn stop_reason_serializes_as_snake_case_string() { - let c = CompletionExtension { - stop_reason: Some(StopReason::MaxTokens), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_completion(&c, &mut bag); - assert_eq!(bag.get_string("completion.stop_reason"), Some("max_tokens")); - } - - #[test] - fn tokens_flatten_to_nested_ints() { - let c = CompletionExtension { - tokens: Some(TokenUsage { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - }), - latency_ms: Some(420), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_completion(&c, &mut bag); - assert_eq!(bag.get_int("completion.tokens.input"), Some(100)); - assert_eq!(bag.get_int("completion.tokens.output"), Some(50)); - assert_eq!(bag.get_int("completion.tokens.total"), Some(150)); - assert_eq!(bag.get_int("completion.latency_ms"), Some(420)); - } -} diff --git a/crates/apl-cmf/src/constants.rs b/crates/apl-cmf/src/constants.rs deleted file mode 100644 index aba127a3..00000000 --- a/crates/apl-cmf/src/constants.rs +++ /dev/null @@ -1,111 +0,0 @@ -// Location: ./crates/apl-cmf/src/constants.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// String constants used across apl-cmf — capability names cpex-core -// recognizes for `filter_extensions`, plus the bag-attribute -// prefixes APL extractors write under. Centralizing both makes the -// capability → bag namespace mapping in `capability_namespaces` a -// straight reference rather than a soup of inline strings, and -// gives operators / docs / tools one canonical place to read them -// from. -// -// # Source-of-truth invariants -// -// * `CAP_*` names match `cpex_core::extensions::filter::filter_extensions` -// verbatim. cpex-core is authoritative — if it changes a cap name, -// bump here and update the mapping table. -// * `BAG_*` prefixes match what the per-extension extractor modules -// (`security.rs`, `delegation.rs`, etc.) actually write into the -// bag. The extractor files still use string literals today; a -// future cleanup can refactor them to consume these constants to -// prevent drift. Tests in `capability_namespaces` flag the -// contract. - -pub const CAP_READ_SUBJECT: &str = "read_subject"; -pub const CAP_READ_ROLES: &str = "read_roles"; -pub const CAP_READ_PERMISSIONS: &str = "read_permissions"; -pub const CAP_READ_TEAMS: &str = "read_teams"; -pub const CAP_READ_CLAIMS: &str = "read_claims"; - -pub const CAP_READ_LABELS: &str = "read_labels"; -pub const CAP_READ_CLIENT: &str = "read_client"; -pub const CAP_READ_WORKLOAD: &str = "read_workload"; - -pub const CAP_READ_INBOUND_CREDENTIALS: &str = "read_inbound_credentials"; -pub const CAP_READ_DELEGATED_TOKENS: &str = "read_delegated_tokens"; - -pub const CAP_READ_DELEGATION: &str = "read_delegation"; -pub const CAP_READ_AGENT: &str = "read_agent"; -pub const CAP_READ_META: &str = "read_meta"; -pub const CAP_READ_REQUEST: &str = "read_request"; -pub const CAP_READ_HEADERS: &str = "read_headers"; -pub const CAP_READ_LLM: &str = "read_llm"; -pub const CAP_READ_MCP: &str = "read_mcp"; -pub const CAP_READ_COMPLETION: &str = "read_completion"; -pub const CAP_READ_PROVENANCE: &str = "read_provenance"; -pub const CAP_READ_FRAMEWORK: &str = "read_framework"; -pub const CAP_READ_CUSTOM: &str = "read_custom"; - -pub const CAP_APPEND_LABELS: &str = "append_labels"; -pub const CAP_APPEND_DELEGATION: &str = "append_delegation"; -pub const CAP_WRITE_HEADERS: &str = "write_headers"; - -// Bag-attribute prefixes (and exact-match keys) — must match what -// the apl-cmf extractor modules write. -// -// Prefixes ending in `.` match any key starting with them -// (e.g. `BAG_ROLE_PREFIX` matches `role.hr`, `role.admin`). -// Prefixes WITHOUT a trailing `.` match the exact bag key -// (e.g. `BAG_AUTHENTICATED` matches only `authenticated`). -pub const BAG_SUBJECT_ID: &str = "subject.id"; -pub const BAG_SUBJECT_TYPE: &str = "subject.type"; -pub const BAG_SUBJECT_TEAMS: &str = "subject.teams"; -pub const BAG_AUTHENTICATED: &str = "authenticated"; -pub const BAG_ROLE_PREFIX: &str = "role."; -pub const BAG_PERM_PREFIX: &str = "perm."; -pub const BAG_TEAM_PREFIX: &str = "team."; -pub const BAG_CLAIM_PREFIX: &str = "claim."; - -// Payload (args / result). -// -// These are the dotted-prefix forms used when apl-cmf::payload flattens -// the request's args object and the upstream's result object into the -// bag. APL predicates / Cedar `${args.X}` substitutions / OPA `input.X` -// paths all resolve through these. -pub const BAG_ARGS_PREFIX: &str = "args."; -pub const BAG_RESULT_PREFIX: &str = "result."; - -pub const BAG_CLIENT_PREFIX: &str = "client."; -pub const BAG_WORKLOAD_PREFIX: &str = "workload."; -pub const BAG_CALLER_WORKLOAD_PREFIX: &str = "caller_workload."; - -pub const BAG_DELEGATION_PREFIX: &str = "delegation."; -pub const BAG_DELEGATED: &str = "delegated"; - -pub const BAG_AGENT_PREFIX: &str = "agent."; -pub const BAG_META_PREFIX: &str = "meta."; -pub const BAG_REQUEST_PREFIX: &str = "request."; -pub const BAG_HTTP_REQUEST_HEADERS_PREFIX: &str = "http.request_headers."; -pub const BAG_HTTP_RESPONSE_HEADERS_PREFIX: &str = "http.response_headers."; -// HTTP request line — exact keys. These ride the same `read_headers` -// capability as headers (the whole `http` slot is gated together in -// `cpex-core::extensions::filter`). -pub const BAG_HTTP_METHOD: &str = "http.method"; -pub const BAG_HTTP_PATH: &str = "http.path"; -pub const BAG_HTTP_HOST: &str = "http.host"; -pub const BAG_HTTP_SCHEME: &str = "http.scheme"; -// Violation `details` keys carrying a transpiled `denyWith` (custom HTTP -// denial response). Shared between the producer (apl-cpex route handler) -// and any consumer (host renderer / tests) so the stringly-typed contract -// stays coupled to one definition. -pub const DETAIL_HTTP_STATUS: &str = "http.status"; -pub const DETAIL_HTTP_BODY: &str = "http.body"; -pub const DETAIL_HTTP_HEADERS: &str = "http.headers"; -pub const BAG_LLM_PREFIX: &str = "llm."; -pub const BAG_MCP_PREFIX: &str = "mcp."; -pub const BAG_COMPLETION_PREFIX: &str = "completion."; -pub const BAG_PROVENANCE_PREFIX: &str = "provenance."; -pub const BAG_FRAMEWORK_PREFIX: &str = "framework."; -pub const BAG_CUSTOM_PREFIX: &str = "custom."; diff --git a/crates/apl-cmf/src/custom.rs b/crates/apl-cmf/src/custom.rs deleted file mode 100644 index 44dfa9fc..00000000 --- a/crates/apl-cmf/src/custom.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Location: ./crates/apl-cmf/src/custom.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `Extensions.custom` (HashMap) → AttributeBag. -// -// Open-ended user namespace. Each top-level key becomes `custom.`, -// and nested objects flatten through the same JSON walker as args/result. -// Lets a host stuff arbitrary policy-relevant data into the bag without -// needing a new extension type. -// -// Namespace: -// custom. : Bool | Int | Float | String | StringSet - -use apl_core::AttributeBag; -use serde_json::Value; -use std::collections::HashMap; - -pub fn extract_custom(custom: &HashMap, bag: &mut AttributeBag) { - for (k, v) in custom { - crate::payload::walk(v, &format!("custom.{}", k), bag); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn custom_keys_flatten_under_custom_namespace() { - let mut custom = HashMap::new(); - custom.insert("feature_flag".into(), json!(true)); - custom.insert( - "tenant".into(), - json!({ "id": "acme", "tier": "enterprise" }), - ); - let mut bag = AttributeBag::new(); - extract_custom(&custom, &mut bag); - assert_eq!(bag.get_bool("custom.feature_flag"), Some(true)); - assert_eq!(bag.get_string("custom.tenant.id"), Some("acme")); - assert_eq!(bag.get_string("custom.tenant.tier"), Some("enterprise")); - } -} diff --git a/crates/apl-cmf/src/delegation.rs b/crates/apl-cmf/src/delegation.rs deleted file mode 100644 index 647d4a14..00000000 --- a/crates/apl-cmf/src/delegation.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Location: ./crates/apl-cmf/src/delegation.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// DelegationExtension → AttributeBag. -// -// Namespace map: -// -// del.depth → delegation.depth : Int -// del.delegated → delegation.delegated, delegated : Bool -// del.origin_subject_id → delegation.origin_subject_id : String -// del.actor_subject_id → delegation.actor_subject_id : String -// del.age_seconds → delegation.age_seconds : Float -// -// Per-hop fields (scopes, audience, strategy) are not flattened into the -// bag. Policies that need that depth call out to a plugin or PDP; the -// bag stays scalar. - -use apl_core::AttributeBag; -use cpex_core::extensions::DelegationExtension; - -/// Flatten a `DelegationExtension` into the bag. -pub fn extract_delegation(del: &DelegationExtension, bag: &mut AttributeBag) { - bag.set("delegation.depth", del.depth as i64); - bag.set("delegation.delegated", del.delegated); - // Top-level alias — DSL idiom is `require(!delegated)`, unprefixed. - bag.set("delegated", del.delegated); - - if let Some(origin) = &del.origin_subject_id { - bag.set("delegation.origin_subject_id", origin.clone()); - } - if let Some(actor) = &del.actor_subject_id { - bag.set("delegation.actor_subject_id", actor.clone()); - } - bag.set("delegation.age_seconds", del.age_seconds); -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::{DelegationHop, DelegationStrategy}; - - #[test] - fn empty_delegation_sets_zero_depth_and_delegated_false() { - let del = DelegationExtension::default(); - let mut bag = AttributeBag::new(); - extract_delegation(&del, &mut bag); - assert_eq!(bag.get_int("delegation.depth"), Some(0)); - assert_eq!(bag.get_bool("delegation.delegated"), Some(false)); - assert_eq!(bag.get_bool("delegated"), Some(false)); - // Optional fields stay absent. - assert!(!bag.contains("delegation.origin_subject_id")); - assert!(!bag.contains("delegation.actor_subject_id")); - } - - #[test] - fn populated_chain_produces_attributes() { - let mut del = DelegationExtension { - origin_subject_id: Some("alice".into()), - actor_subject_id: Some("service-b".into()), - age_seconds: 12.5, - ..Default::default() - }; - del.append_hop(DelegationHop { - subject_id: "alice".into(), - audience: Some("service-b".into()), - scopes_granted: vec!["read".into()], - strategy: Some(DelegationStrategy::TokenExchange), - ..Default::default() - }); - del.append_hop(DelegationHop { - subject_id: "service-b".into(), - audience: Some("service-c".into()), - scopes_granted: vec!["read".into()], - ..Default::default() - }); - - let mut bag = AttributeBag::new(); - extract_delegation(&del, &mut bag); - assert_eq!(bag.get_int("delegation.depth"), Some(2)); - assert_eq!(bag.get_bool("delegation.delegated"), Some(true)); - assert_eq!(bag.get_bool("delegated"), Some(true)); - assert_eq!( - bag.get_string("delegation.origin_subject_id"), - Some("alice") - ); - assert_eq!( - bag.get_string("delegation.actor_subject_id"), - Some("service-b") - ); - assert_eq!(bag.get_float("delegation.age_seconds"), Some(12.5)); - } -} diff --git a/crates/apl-cmf/src/extensions_bridge.rs b/crates/apl-cmf/src/extensions_bridge.rs deleted file mode 100644 index 9f36dcb3..00000000 --- a/crates/apl-cmf/src/extensions_bridge.rs +++ /dev/null @@ -1,118 +0,0 @@ -// Location: ./crates/apl-cmf/src/extensions_bridge.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Unified entry point: take an `Extensions` container, dispatch each -// present slot to its per-extension extractor. -// -// This is the function `apl-cpex` will call at hook time after assembling -// `Extensions` from the request. It guarantees every slot that's present -// gets bridged, so a new extension type that adds an extractor module -// shows up in the bag automatically. - -use apl_core::AttributeBag; -use cpex_core::extensions::Extensions; - -use crate::{ - agent::extract_agent, completion::extract_completion, custom::extract_custom, - delegation::extract_delegation, framework::extract_framework, http::extract_http, - llm::extract_llm, mcp::extract_mcp, meta::extract_meta, provenance::extract_provenance, - request::extract_request, security::extract_security, -}; - -/// Flatten every present slot in `Extensions` into `bag`. -pub fn extract_extensions(ext: &Extensions, bag: &mut AttributeBag) { - if let Some(v) = &ext.security { - extract_security(v, bag); - } - if let Some(v) = &ext.delegation { - extract_delegation(v, bag); - } - if let Some(v) = &ext.agent { - extract_agent(v, bag); - } - if let Some(v) = &ext.meta { - extract_meta(v, bag); - } - if let Some(v) = &ext.request { - extract_request(v, bag); - } - if let Some(v) = &ext.http { - extract_http(v, bag); - } - if let Some(v) = &ext.llm { - extract_llm(v, bag); - } - if let Some(v) = &ext.mcp { - extract_mcp(v, bag); - } - if let Some(v) = &ext.completion { - extract_completion(v, bag); - } - if let Some(v) = &ext.provenance { - extract_provenance(v, bag); - } - if let Some(v) = &ext.framework { - extract_framework(v, bag); - } - if let Some(v) = &ext.custom { - extract_custom(v, bag); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::{ - AgentExtension, DelegationExtension, LLMExtension, MetaExtension, SecurityExtension, - SubjectExtension, - }; - use std::collections::HashSet; - use std::sync::Arc; - - #[test] - fn dispatches_every_present_slot() { - let mut ext = Extensions::default(); - ext.security = Some(Arc::new(SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice".into()), - roles: HashSet::from(["hr".to_string()]), - ..Default::default() - }), - ..Default::default() - })); - ext.delegation = Some(Arc::new(DelegationExtension::default())); - ext.agent = Some(Arc::new(AgentExtension { - session_id: Some("sess-1".into()), - ..Default::default() - })); - ext.meta = Some(Arc::new(MetaExtension { - tags: HashSet::from(["pii".to_string()]), - ..Default::default() - })); - ext.llm = Some(Arc::new(LLMExtension { - model_id: Some("gpt-4".into()), - ..Default::default() - })); - - let mut bag = AttributeBag::new(); - extract_extensions(&ext, &mut bag); - - // One assertion per namespace — proves the dispatch reached each. - assert_eq!(bag.get_string("subject.id"), Some("alice")); - assert_eq!(bag.get_bool("role.hr"), Some(true)); - assert_eq!(bag.get_int("delegation.depth"), Some(0)); - assert_eq!(bag.get_string("agent.session_id"), Some("sess-1")); - assert!(bag.set_contains("meta.tags", "pii")); - assert_eq!(bag.get_string("llm.model_id"), Some("gpt-4")); - } - - #[test] - fn absent_slots_skipped_no_panic() { - let ext = Extensions::default(); - let mut bag = AttributeBag::new(); - extract_extensions(&ext, &mut bag); - assert!(bag.is_empty()); - } -} diff --git a/crates/apl-cmf/src/framework.rs b/crates/apl-cmf/src/framework.rs deleted file mode 100644 index aba556ed..00000000 --- a/crates/apl-cmf/src/framework.rs +++ /dev/null @@ -1,63 +0,0 @@ -// Location: ./crates/apl-cmf/src/framework.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// FrameworkExtension → AttributeBag. -// -// Namespace: -// framework.framework : String ("langchain", "crewai", ...) -// framework.framework_version : String -// framework.node_id : String -// framework.graph_id : String -// framework.metadata. : various (JSON walker — same as args) - -use apl_core::AttributeBag; -use cpex_core::extensions::FrameworkExtension; - -pub fn extract_framework(f: &FrameworkExtension, bag: &mut AttributeBag) { - if let Some(v) = &f.framework { - bag.set("framework.framework", v.clone()); - } - if let Some(v) = &f.framework_version { - bag.set("framework.framework_version", v.clone()); - } - if let Some(v) = &f.node_id { - bag.set("framework.node_id", v.clone()); - } - if let Some(v) = &f.graph_id { - bag.set("framework.graph_id", v.clone()); - } - // metadata is a HashMap — flatten the same way args/result do. - for (k, v) in &f.metadata { - crate::payload::walk(v, &format!("framework.metadata.{}", k), bag); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::collections::HashMap; - - #[test] - fn nested_metadata_flattens() { - let f = FrameworkExtension { - framework: Some("langchain".into()), - framework_version: Some("0.1.42".into()), - node_id: Some("retriever".into()), - metadata: HashMap::from([ - ("chain_id".to_string(), json!("abc")), - ("step".to_string(), json!(7)), - ("flags".to_string(), json!({ "verbose": true })), - ]), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_framework(&f, &mut bag); - assert_eq!(bag.get_string("framework.framework"), Some("langchain")); - assert_eq!(bag.get_string("framework.metadata.chain_id"), Some("abc")); - assert_eq!(bag.get_int("framework.metadata.step"), Some(7)); - assert_eq!(bag.get_bool("framework.metadata.flags.verbose"), Some(true)); - } -} diff --git a/crates/apl-cmf/src/http.rs b/crates/apl-cmf/src/http.rs deleted file mode 100644 index b0950bb4..00000000 --- a/crates/apl-cmf/src/http.rs +++ /dev/null @@ -1,103 +0,0 @@ -// Location: ./crates/apl-cmf/src/http.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// HttpExtension → AttributeBag. -// -// Header names are lowercased in the bag (HTTP is case-insensitive). A -// policy author writing `http.request_headers.authorization` doesn't need -// to remember the original case. -// -// Namespace: -// http.method : String (request line) -// http.path : String -// http.host : String -// http.scheme : String -// http.request_headers. : String (lowercased name) -// http.response_headers. : String (lowercased name) - -use apl_core::AttributeBag; -use cpex_core::extensions::HttpExtension; - -use crate::constants::{BAG_HTTP_HOST, BAG_HTTP_METHOD, BAG_HTTP_PATH, BAG_HTTP_SCHEME}; - -pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { - if let Some(method) = &http.method { - bag.set(BAG_HTTP_METHOD.to_string(), method.clone()); - } - if let Some(path) = &http.path { - bag.set(BAG_HTTP_PATH.to_string(), path.clone()); - } - if let Some(host) = &http.host { - bag.set(BAG_HTTP_HOST.to_string(), host.clone()); - } - if let Some(scheme) = &http.scheme { - bag.set(BAG_HTTP_SCHEME.to_string(), scheme.clone()); - } - for (k, v) in &http.request_headers { - bag.set( - format!("http.request_headers.{}", k.to_lowercase()), - v.clone(), - ); - } - for (k, v) in &http.response_headers { - bag.set( - format!("http.response_headers.{}", k.to_lowercase()), - v.clone(), - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn request_line_surfaced_in_bag() { - let http = HttpExtension { - method: Some("POST".to_string()), - path: Some("/api/widgets".to_string()), - host: Some("api.example.com".to_string()), - scheme: Some("https".to_string()), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_http(&http, &mut bag); - assert_eq!(bag.get_string("http.method"), Some("POST")); - assert_eq!(bag.get_string("http.path"), Some("/api/widgets")); - assert_eq!(bag.get_string("http.host"), Some("api.example.com")); - assert_eq!(bag.get_string("http.scheme"), Some("https")); - } - - #[test] - fn request_line_absent_when_unset() { - let http = HttpExtension::default(); - let mut bag = AttributeBag::new(); - extract_http(&http, &mut bag); - assert_eq!(bag.get_string("http.method"), None); - } - - #[test] - fn headers_lowercased_in_bag() { - let mut http = HttpExtension::default(); - http.set_request_header("Authorization", "Bearer xyz"); - http.set_request_header("X-Trace-Id", "abc-123"); - http.set_response_header("Content-Type", "application/json"); - - let mut bag = AttributeBag::new(); - extract_http(&http, &mut bag); - assert_eq!( - bag.get_string("http.request_headers.authorization"), - Some("Bearer xyz") - ); - assert_eq!( - bag.get_string("http.request_headers.x-trace-id"), - Some("abc-123") - ); - assert_eq!( - bag.get_string("http.response_headers.content-type"), - Some("application/json") - ); - } -} diff --git a/crates/apl-cmf/src/lib.rs b/crates/apl-cmf/src/lib.rs deleted file mode 100644 index 5ba22ad7..00000000 --- a/crates/apl-cmf/src/lib.rs +++ /dev/null @@ -1,154 +0,0 @@ -// Location: ./crates/apl-cmf/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// apl-cmf — bridges typed cpex-core extensions into apl-core's flat -// AttributeBag. This is where the *attribute vocabulary* APL policy -// authors write against gets defined. -// -// Layering: -// -// cpex-core : typed extension data (SecurityExtension, …) -// apl-cmf : ←── this crate, flat-key bridge -// apl-core : language IR + evaluator (AttributeBag, predicates, pipelines) -// apl-cpex : runtime adapter (hooks, PluginInvoker, PdpResolver) -// -// The crate is intentionally simple: each bridge is a pure function that -// reads its typed source and writes flat keys into a borrowed bag. No -// async, no I/O. Composition is via the convenience `BagBuilder`. -// -// Attribute namespace contract (each module owns the detail comment): -// SecurityExtension.subject → subject.*, role.*, perm.*, claim.*, authenticated -// SecurityExtension.client → client.*, client.role.*, client.perm.*, client.claim.* -// SecurityExtension.caller_workload → caller_workload.* (inbound attested peer) -// SecurityExtension.this_workload → this_workload.* (our own attested identity — -// not `agent.*`, which is `AgentExtension`) -// SecurityExtension → security.labels, security.classification, auth_method -// DelegationExtension → delegation.*, delegated -// AgentExtension → agent.* (session, conversation, lineage) -// MetaExtension → meta.* -// RequestExtension → request.* -// HttpExtension → http.method, http.path, http.host, http.scheme, -// http.request_headers.*, http.response_headers.* -// LLMExtension → llm.* -// MCPExtension → mcp.tool.*, mcp.resource.*, mcp.prompt.* -// CompletionExtension → completion.* -// ProvenanceExtension → provenance.* -// FrameworkExtension → framework.* (incl. framework.metadata.*) -// Extensions.custom → custom.* -// Request args object → args.* -// Response result object → result.* - -pub mod agent; -pub mod capability_namespaces; -pub mod completion; -pub mod constants; -pub mod custom; -pub mod delegation; -pub mod extensions_bridge; -pub mod framework; -pub mod http; -pub mod llm; -pub mod mcp; -pub mod meta; -pub mod payload; -pub mod provenance; -pub mod request; -pub mod security; - -pub use agent::extract_agent; -pub use capability_namespaces::{ - capability_namespaces, known_read_capabilities, unlocked_bag_prefixes, -}; -pub use completion::extract_completion; -pub use custom::extract_custom; -pub use delegation::extract_delegation; -pub use extensions_bridge::extract_extensions; -pub use framework::extract_framework; -pub use http::extract_http; -pub use llm::extract_llm; -pub use mcp::extract_mcp; -pub use meta::extract_meta; -pub use payload::{extract_args, extract_data, extract_result}; -pub use provenance::extract_provenance; -pub use request::extract_request; -pub use security::{extract_client, extract_security, extract_workload}; - -use apl_core::AttributeBag; -use cpex_core::extensions::{DelegationExtension, Extensions, SecurityExtension}; - -/// Fluent builder that composes the typed sources into a single bag. -/// -/// Lets the host (apl-cpex) write: -/// ```ignore -/// let bag = BagBuilder::new() -/// .with_security(&sec) -/// .with_delegation(&del) -/// .with_args(&payload.args) -/// .build(); -/// ``` -/// -/// Order of `with_*` calls is irrelevant — keys live in disjoint namespaces. -#[derive(Default)] -pub struct BagBuilder { - bag: AttributeBag, -} - -impl BagBuilder { - pub fn new() -> Self { - Self::default() - } - - pub fn with_security(mut self, sec: &SecurityExtension) -> Self { - extract_security(sec, &mut self.bag); - self - } - - pub fn with_delegation(mut self, del: &DelegationExtension) -> Self { - extract_delegation(del, &mut self.bag); - self - } - - /// Bridge every present slot in an `Extensions` container at once — - /// security, delegation, agent, meta, request, http, llm, mcp, - /// completion, provenance, framework, custom. - pub fn with_extensions(mut self, ext: &Extensions) -> Self { - extract_extensions(ext, &mut self.bag); - self - } - - pub fn with_args(mut self, args: &serde_json::Value) -> Self { - extract_args(args, &mut self.bag); - self - } - - pub fn with_result(mut self, result: &serde_json::Value) -> Self { - extract_result(result, &mut self.bag); - self - } - - /// Flatten a static attribute tree into the `data.*` namespace. - /// The tree is shared and startup-loaded, but this - /// re-walks it and re-inserts every leaf into the bag on **each call** - /// (a `format!` per node, a `bag.set` per leaf) — so `data.*` reads are - /// **not** free on the request hot path. The route handler invokes this - /// per request (once per phase), meaning a large tree is re-flattened on - /// every request until a per-request caching optimization lands. - pub fn with_data(mut self, tree: &apl_core::AttributeTree) -> Self { - extract_data(tree, &mut self.bag); - self - } - - /// Set the route key under `route.key` for policy predicates that - /// branch on which route is running (mostly useful in default/policy - /// bundles applied across routes). - pub fn with_route_key(mut self, route_key: impl Into) -> Self { - self.bag.set("route.key", route_key.into()); - self - } - - pub fn build(self) -> AttributeBag { - self.bag - } -} diff --git a/crates/apl-cmf/src/llm.rs b/crates/apl-cmf/src/llm.rs deleted file mode 100644 index 51d964da..00000000 --- a/crates/apl-cmf/src/llm.rs +++ /dev/null @@ -1,48 +0,0 @@ -// Location: ./crates/apl-cmf/src/llm.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// LLMExtension → AttributeBag. -// -// Namespace: -// llm.model_id : String -// llm.provider : String -// llm.capabilities : StringSet - -use apl_core::AttributeBag; -use cpex_core::extensions::LLMExtension; -use std::collections::HashSet; - -pub fn extract_llm(llm: &LLMExtension, bag: &mut AttributeBag) { - if let Some(v) = &llm.model_id { - bag.set("llm.model_id", v.clone()); - } - if let Some(v) = &llm.provider { - bag.set("llm.provider", v.clone()); - } - if !llm.capabilities.is_empty() { - let caps: HashSet = llm.capabilities.iter().cloned().collect(); - bag.set("llm.capabilities", caps); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_model_and_capabilities() { - let llm = LLMExtension { - model_id: Some("gpt-4".into()), - provider: Some("openai".into()), - capabilities: vec!["tool_use".into(), "vision".into()], - }; - let mut bag = AttributeBag::new(); - extract_llm(&llm, &mut bag); - assert_eq!(bag.get_string("llm.model_id"), Some("gpt-4")); - assert_eq!(bag.get_string("llm.provider"), Some("openai")); - assert!(bag.set_contains("llm.capabilities", "tool_use")); - assert!(bag.set_contains("llm.capabilities", "vision")); - } -} diff --git a/crates/apl-cmf/src/mcp.rs b/crates/apl-cmf/src/mcp.rs deleted file mode 100644 index 63f3add9..00000000 --- a/crates/apl-cmf/src/mcp.rs +++ /dev/null @@ -1,121 +0,0 @@ -// Location: ./crates/apl-cmf/src/mcp.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// MCPExtension → AttributeBag. -// -// Tool, resource, and prompt metadata each flatten under their own sub-namespace. -// Schemas and annotations are deliberately NOT flattened — they're free-form -// JSON; policies that need them should call a plugin. -// -// Namespace: -// mcp.tool.name : String (always set if tool present) -// mcp.tool.title : String -// mcp.tool.description : String -// mcp.tool.server_id : String -// mcp.tool.namespace : String -// mcp.resource.uri : String (always set if resource present) -// mcp.resource.name : String -// mcp.resource.description: String -// mcp.resource.mime_type : String -// mcp.resource.server_id : String -// mcp.prompt.name : String (always set if prompt present) -// mcp.prompt.description : String -// mcp.prompt.server_id : String - -use apl_core::AttributeBag; -use cpex_core::extensions::MCPExtension; - -pub fn extract_mcp(mcp: &MCPExtension, bag: &mut AttributeBag) { - if let Some(tool) = &mcp.tool { - bag.set("mcp.tool.name", tool.name.clone()); - if let Some(v) = &tool.title { - bag.set("mcp.tool.title", v.clone()); - } - if let Some(v) = &tool.description { - bag.set("mcp.tool.description", v.clone()); - } - if let Some(v) = &tool.server_id { - bag.set("mcp.tool.server_id", v.clone()); - } - if let Some(v) = &tool.namespace { - bag.set("mcp.tool.namespace", v.clone()); - } - } - if let Some(res) = &mcp.resource { - bag.set("mcp.resource.uri", res.uri.clone()); - if let Some(v) = &res.name { - bag.set("mcp.resource.name", v.clone()); - } - if let Some(v) = &res.description { - bag.set("mcp.resource.description", v.clone()); - } - if let Some(v) = &res.mime_type { - bag.set("mcp.resource.mime_type", v.clone()); - } - if let Some(v) = &res.server_id { - bag.set("mcp.resource.server_id", v.clone()); - } - } - if let Some(prompt) = &mcp.prompt { - bag.set("mcp.prompt.name", prompt.name.clone()); - if let Some(v) = &prompt.description { - bag.set("mcp.prompt.description", v.clone()); - } - if let Some(v) = &prompt.server_id { - bag.set("mcp.prompt.server_id", v.clone()); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::mcp::{ResourceMetadata, ToolMetadata}; - - #[test] - fn tool_metadata_flattens() { - let mcp = MCPExtension { - tool: Some(ToolMetadata { - name: "get_compensation".into(), - description: Some("HR comp lookup".into()), - server_id: Some("hr-srv".into()), - ..Default::default() - }), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_mcp(&mcp, &mut bag); - assert_eq!(bag.get_string("mcp.tool.name"), Some("get_compensation")); - assert_eq!( - bag.get_string("mcp.tool.description"), - Some("HR comp lookup") - ); - assert_eq!(bag.get_string("mcp.tool.server_id"), Some("hr-srv")); - // Schemas are deliberately not in the bag. - assert!(!bag.contains("mcp.tool.input_schema")); - } - - #[test] - fn resource_uri_is_required_field() { - let mcp = MCPExtension { - resource: Some(ResourceMetadata { - uri: "hr://employees/123".into(), - mime_type: Some("application/json".into()), - ..Default::default() - }), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_mcp(&mcp, &mut bag); - assert_eq!( - bag.get_string("mcp.resource.uri"), - Some("hr://employees/123") - ); - assert_eq!( - bag.get_string("mcp.resource.mime_type"), - Some("application/json") - ); - } -} diff --git a/crates/apl-cmf/src/meta.rs b/crates/apl-cmf/src/meta.rs deleted file mode 100644 index 1444e753..00000000 --- a/crates/apl-cmf/src/meta.rs +++ /dev/null @@ -1,61 +0,0 @@ -// Location: ./crates/apl-cmf/src/meta.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// MetaExtension → AttributeBag. -// -// Namespace: -// meta.entity_type : String ("tool" | "resource" | "prompt" | "llm") -// meta.entity_name : String -// meta.tags : StringSet ← used by spec-level tag-driven policy inheritance -// meta.scope : String -// meta.properties. : String - -use apl_core::AttributeBag; -use cpex_core::extensions::MetaExtension; -use std::collections::HashSet; - -pub fn extract_meta(meta: &MetaExtension, bag: &mut AttributeBag) { - if let Some(v) = &meta.entity_type { - bag.set("meta.entity_type", v.clone()); - } - if let Some(v) = &meta.entity_name { - bag.set("meta.entity_name", v.clone()); - } - if !meta.tags.is_empty() { - let tags: HashSet = meta.tags.iter().cloned().collect(); - bag.set("meta.tags", tags); - } - if let Some(v) = &meta.scope { - bag.set("meta.scope", v.clone()); - } - for (k, v) in &meta.properties { - bag.set(format!("meta.properties.{}", k), v.clone()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - #[test] - fn tags_and_properties_flatten() { - let meta = MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - tags: HashSet::from(["pii".to_string(), "sensitive".to_string()]), - scope: Some("hr".into()), - properties: HashMap::from([("owner".to_string(), "compliance".to_string())]), - }; - let mut bag = AttributeBag::new(); - extract_meta(&meta, &mut bag); - assert_eq!(bag.get_string("meta.entity_type"), Some("tool")); - assert_eq!(bag.get_string("meta.entity_name"), Some("get_compensation")); - assert!(bag.set_contains("meta.tags", "pii")); - assert!(bag.set_contains("meta.tags", "sensitive")); - assert_eq!(bag.get_string("meta.scope"), Some("hr")); - assert_eq!(bag.get_string("meta.properties.owner"), Some("compliance")); - } -} diff --git a/crates/apl-cmf/src/payload.rs b/crates/apl-cmf/src/payload.rs deleted file mode 100644 index ad2d4f98..00000000 --- a/crates/apl-cmf/src/payload.rs +++ /dev/null @@ -1,191 +0,0 @@ -// Location: ./crates/apl-cmf/src/payload.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// JSON args/result payload → AttributeBag. -// -// Leaf scalars at any nesting depth land in the bag under their dotted -// path, prefixed with `args.` or `result.`. Nested objects recurse; -// arrays-of-strings flatten into a StringSet; arrays of mixed/scalar -// types are skipped (no list scalar attribute in the bag). -// -// Examples: -// args = { "include_ssn": true, -// "user": { "id": "alice", "roles": ["hr", "manager"] } } -// → args.include_ssn : Bool(true) -// args.user.id : String("alice") -// args.user.roles : StringSet({"hr", "manager"}) -// -// Null values are skipped (consistent with bag's missing-key semantics). - -use apl_core::AttributeBag; -use serde_json::Value; -use std::collections::HashSet; - -use crate::constants::{BAG_ARGS_PREFIX, BAG_RESULT_PREFIX}; - -/// Flatten an args object into `args.*` keys. -pub fn extract_args(args: &Value, bag: &mut AttributeBag) { - // `walk` builds dotted paths itself; strip the trailing `.` from - // the canonical prefix to match its signature. - walk(args, BAG_ARGS_PREFIX.trim_end_matches('.'), bag); -} - -/// Flatten a result object into `result.*` keys. -pub fn extract_result(result: &Value, bag: &mut AttributeBag) { - walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag); -} - -/// Flatten a static attribute tree into `data.*` keys. Same walk as -/// args/result — nested objects recurse, string arrays become -/// `StringSet`s (so `data.tenants.x.allowed_models` supports `contains` -/// and interpolated `restrict` references). -pub fn extract_data(tree: &apl_core::AttributeTree, bag: &mut AttributeBag) { - walk(tree.as_value(), "data", bag); -} - -pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) { - match value { - Value::Object(map) => { - for (key, sub) in map { - let dotted = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - walk(sub, &dotted, bag); - } - }, - Value::Array(items) => { - // Promote string-only arrays to StringSet — supports - // `args.tags contains "urgent"` predicates. - let mut all_strings: HashSet = HashSet::new(); - let mut ok = true; - for item in items { - if let Some(s) = item.as_str() { - all_strings.insert(s.to_string()); - } else { - ok = false; - break; - } - } - if ok && !all_strings.is_empty() { - bag.set(prefix, all_strings); - } - // Non-string arrays (mixed, numeric, nested): silently skipped - // — no list scalar in the bag for those. - }, - Value::String(s) => bag.set(prefix, s.clone()), - Value::Bool(b) => bag.set(prefix, *b), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - bag.set(prefix, i); - } else if let Some(f) = n.as_f64() { - bag.set(prefix, f); - } - }, - Value::Null => {}, // Skip — equivalent to "key not present." - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn args_scalars_at_top_level() { - let args = json!({ "include_ssn": true, "amount": 100, "name": "alice" }); - let mut bag = AttributeBag::new(); - extract_args(&args, &mut bag); - assert_eq!(bag.get_bool("args.include_ssn"), Some(true)); - assert_eq!(bag.get_int("args.amount"), Some(100)); - assert_eq!(bag.get_string("args.name"), Some("alice")); - } - - #[test] - fn args_nested_objects_dotted() { - let args = json!({ "user": { "id": "alice", "profile": { "tier": "gold" } } }); - let mut bag = AttributeBag::new(); - extract_args(&args, &mut bag); - assert_eq!(bag.get_string("args.user.id"), Some("alice")); - assert_eq!(bag.get_string("args.user.profile.tier"), Some("gold")); - } - - #[test] - fn args_string_array_becomes_string_set() { - let args = json!({ "tags": ["urgent", "audit"] }); - let mut bag = AttributeBag::new(); - extract_args(&args, &mut bag); - assert!(bag.set_contains("args.tags", "urgent")); - assert!(bag.set_contains("args.tags", "audit")); - assert!(!bag.set_contains("args.tags", "missing")); - } - - #[test] - fn args_mixed_array_is_skipped() { - let args = json!({ "mixed": ["a", 1, true] }); - let mut bag = AttributeBag::new(); - extract_args(&args, &mut bag); - // No `args.mixed` key — type didn't unify, so we dropped it. - assert!(!bag.contains("args.mixed")); - } - - #[test] - fn args_null_is_treated_as_missing() { - let args = json!({ "maybe": null, "yes": true }); - let mut bag = AttributeBag::new(); - extract_args(&args, &mut bag); - assert!(!bag.contains("args.maybe")); - assert_eq!(bag.get_bool("args.yes"), Some(true)); - } - - #[test] - fn result_uses_result_prefix() { - let result = json!({ "ssn": "123-45-6789", "salary": 50000 }); - let mut bag = AttributeBag::new(); - extract_result(&result, &mut bag); - assert_eq!(bag.get_string("result.ssn"), Some("123-45-6789")); - assert_eq!(bag.get_int("result.salary"), Some(50000)); - // No args.* keys collected. - assert!(!bag.contains("args.ssn")); - } - - #[test] - fn float_numbers_land_as_float() { - let args = json!({ "score": 0.92 }); - let mut bag = AttributeBag::new(); - extract_args(&args, &mut bag); - assert_eq!(bag.get_float("args.score"), Some(0.92)); - } - - #[test] - fn data_tree_flattens_under_data_namespace() { - let tree = apl_core::AttributeTree::new(json!({ - "org": { "default_region": "us" }, - "tenants": { - "acme-eu": { "data_region": "eu", "allowed_models": ["anthropic/*", "vllm/*"] } - } - })); - let mut bag = AttributeBag::new(); - extract_data(&tree, &mut bag); - - assert_eq!(bag.get_string("data.org.default_region"), Some("us")); - assert_eq!( - bag.get_string("data.tenants.acme-eu.data_region"), - Some("eu") - ); - // String arrays become a StringSet (ready for `contains` / - // interpolated path lookups). - assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "anthropic/*")); - assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "vllm/*")); - } - - #[test] - fn empty_data_tree_adds_nothing() { - let mut bag = AttributeBag::new(); - extract_data(&apl_core::AttributeTree::empty(), &mut bag); - assert!(bag.is_empty()); - } -} diff --git a/crates/apl-cmf/src/provenance.rs b/crates/apl-cmf/src/provenance.rs deleted file mode 100644 index b675a125..00000000 --- a/crates/apl-cmf/src/provenance.rs +++ /dev/null @@ -1,44 +0,0 @@ -// Location: ./crates/apl-cmf/src/provenance.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// ProvenanceExtension → AttributeBag. -// -// Namespace: -// provenance.source : String -// provenance.message_id : String -// provenance.parent_id : String - -use apl_core::AttributeBag; -use cpex_core::extensions::ProvenanceExtension; - -pub fn extract_provenance(p: &ProvenanceExtension, bag: &mut AttributeBag) { - if let Some(v) = &p.source { - bag.set("provenance.source", v.clone()); - } - if let Some(v) = &p.message_id { - bag.set("provenance.message_id", v.clone()); - } - if let Some(v) = &p.parent_id { - bag.set("provenance.parent_id", v.clone()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_threading_fields() { - let p = ProvenanceExtension { - source: Some("upstream-mcp".into()), - message_id: Some("msg-1".into()), - parent_id: Some("msg-0".into()), - }; - let mut bag = AttributeBag::new(); - extract_provenance(&p, &mut bag); - assert_eq!(bag.get_string("provenance.source"), Some("upstream-mcp")); - assert_eq!(bag.get_string("provenance.parent_id"), Some("msg-0")); - } -} diff --git a/crates/apl-cmf/src/request.rs b/crates/apl-cmf/src/request.rs deleted file mode 100644 index 8f71fe18..00000000 --- a/crates/apl-cmf/src/request.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Location: ./crates/apl-cmf/src/request.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// RequestExtension → AttributeBag. -// -// Namespace: -// request.environment : String ("production" | "staging" | ...) -// request.request_id : String -// request.timestamp : String (ISO 8601 — bag stays scalar; predicates -// comparing timestamps would need plugins) -// request.trace_id : String -// request.span_id : String - -use apl_core::AttributeBag; -use cpex_core::extensions::RequestExtension; - -pub fn extract_request(req: &RequestExtension, bag: &mut AttributeBag) { - if let Some(v) = &req.environment { - bag.set("request.environment", v.clone()); - } - if let Some(v) = &req.request_id { - bag.set("request.request_id", v.clone()); - } - if let Some(v) = &req.timestamp { - bag.set("request.timestamp", v.clone()); - } - if let Some(v) = &req.trace_id { - bag.set("request.trace_id", v.clone()); - } - if let Some(v) = &req.span_id { - bag.set("request.span_id", v.clone()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_all_present_fields() { - let req = RequestExtension { - environment: Some("production".into()), - request_id: Some("req-abc".into()), - timestamp: Some("2026-05-14T12:00:00Z".into()), - trace_id: Some("trace-1".into()), - span_id: Some("span-2".into()), - }; - let mut bag = AttributeBag::new(); - extract_request(&req, &mut bag); - assert_eq!(bag.get_string("request.environment"), Some("production")); - assert_eq!(bag.get_string("request.request_id"), Some("req-abc")); - assert_eq!(bag.get_string("request.trace_id"), Some("trace-1")); - } - - #[test] - fn missing_fields_skipped() { - let req = RequestExtension::default(); - let mut bag = AttributeBag::new(); - extract_request(&req, &mut bag); - assert!(bag.is_empty()); - } -} diff --git a/crates/apl-cmf/src/security.rs b/crates/apl-cmf/src/security.rs deleted file mode 100644 index 39de6938..00000000 --- a/crates/apl-cmf/src/security.rs +++ /dev/null @@ -1,517 +0,0 @@ -// Location: ./crates/apl-cmf/src/security.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// SecurityExtension → AttributeBag. -// -// Namespace map (canonical — extend this comment when adding a new key): -// -// ----- Subject (user identity) ------------------------------------------ -// sec.subject.id → subject.id : String -// sec.subject.subject_type → subject.type : String -// sec.subject.roles → role. : Bool(true) -// sec.subject.permissions → perm.

: Bool(true) -// sec.subject.teams → subject.teams : StringSet -// sec.subject.claims → claim. : String -// → authenticated : Bool (iff subject.id is Some) -// -// ----- Client (OAuth application identity) ------------------------------ -// sec.client.client_id → client.client_id : String -// sec.client.client_name → client.client_name : String -// sec.client.trust_level → client.trust_level : String -// sec.client.authorized_scopes → client.authorized_scopes : StringSet -// sec.client.authorized_audiences → client.authorized_audiences : StringSet -// sec.client.roles → client.role. : Bool(true) -// sec.client.permissions → client.perm.

: Bool(true) -// sec.client.teams → client.teams : StringSet -// sec.client.claims → client.claim. : flattened JSON -// -// ----- Workload identity (SPIFFE / mTLS attestation) -------------------- -// sec.caller_workload.spiffe_id → caller_workload.spiffe_id : String -// sec.caller_workload.trust_domain → caller_workload.trust_domain : String -// sec.caller_workload.attestor → caller_workload.attestor : String -// sec.caller_workload.selectors → caller_workload.selectors : StringSet -// sec.caller_workload.client_id → caller_workload.client_id : String -// sec.this_workload.* → this_workload.* (same shape, our identity) -// -// Note: `caller_workload.*` / `this_workload.*` are separate from -// `agent.*` (the `AgentExtension` slot — session / conversation context, -// NOT a credential). Reusing `agent.*` would collide. -// -// ----- Other ----------------------------------------------------------- -// sec.auth_method → auth_method : String -// sec.labels → security.labels : StringSet -// sec.classification → security.classification : String - -use apl_core::AttributeBag; -use cpex_core::extensions::{ - ClientExtension, ClientTrustLevel, SecurityExtension, SubjectType, WorkloadIdentity, -}; -use std::collections::HashSet; - -use crate::constants::{ - BAG_AUTHENTICATED, BAG_CLAIM_PREFIX, BAG_PERM_PREFIX, BAG_ROLE_PREFIX, BAG_SUBJECT_ID, - BAG_SUBJECT_TEAMS, BAG_SUBJECT_TYPE, BAG_TEAM_PREFIX, -}; - -/// Flatten a `SecurityExtension` into the bag. -pub fn extract_security(sec: &SecurityExtension, bag: &mut AttributeBag) { - if let Some(subject) = &sec.subject { - let mut authenticated = false; - if let Some(id) = &subject.id { - bag.set(BAG_SUBJECT_ID, id.clone()); - authenticated = true; - } - if let Some(st) = subject.subject_type { - bag.set(BAG_SUBJECT_TYPE, subject_type_str(st)); - } - for role in &subject.roles { - bag.set(format!("{}{}", BAG_ROLE_PREFIX, role), true); - } - for perm in &subject.permissions { - bag.set(format!("{}{}", BAG_PERM_PREFIX, perm), true); - } - if !subject.teams.is_empty() { - // Clone into a fresh HashSet — AttributeValue::StringSet owns its data. - let teams: HashSet = subject.teams.iter().cloned().collect(); - bag.set(BAG_SUBJECT_TEAMS, teams); - // Mirror the role.X / perm.X namespace so policies can - // gate on team membership with the same DSL shape, e.g. - // `require(team.engineering | team.security)`. - for team in &subject.teams { - bag.set(format!("{}{}", BAG_TEAM_PREFIX, team), true); - } - } - for (k, v) in &subject.claims { - bag.set(format!("{}{}", BAG_CLAIM_PREFIX, k), v.clone()); - } - // Single top-level authenticated marker — DSL idiom is `require(authenticated)`, - // unprefixed. Only set when truly authenticated (subject + id present). - if authenticated { - bag.set(BAG_AUTHENTICATED, true); - } - } - - if let Some(client) = &sec.client { - extract_client(client, bag); - } - - if let Some(caller) = &sec.caller_workload { - extract_workload("caller_workload", caller, bag); - } - - if let Some(this_w) = &sec.this_workload { - extract_workload("this_workload", this_w, bag); - } - - if let Some(m) = &sec.auth_method { - bag.set("auth_method", m.clone()); - } - let labels: HashSet = sec.labels.iter().cloned().collect(); - if !labels.is_empty() { - bag.set("security.labels", labels); - } - if let Some(c) = &sec.classification { - bag.set("security.classification", c.clone()); - } -} - -/// Flatten a `ClientExtension` into the bag under the `client.*` -/// namespace. Shape is deliberately symmetric with subject — roles -/// and permissions become presence-only `client.role. = true` / -/// `client.perm.

= true` keys so policies can write -/// `require(client.role.partner)` the same way as `role.hr`. Claims -/// are flattened through the same JSON walker as `custom.*`, so -/// nested objects produce dotted-path keys. -pub fn extract_client(client: &ClientExtension, bag: &mut AttributeBag) { - bag.set("client.client_id", client.client_id.clone()); - if let Some(n) = &client.client_name { - bag.set("client.client_name", n.clone()); - } - bag.set("client.trust_level", trust_level_str(&client.trust_level)); - for role in &client.roles { - bag.set(format!("client.role.{}", role), true); - } - for perm in &client.permissions { - bag.set(format!("client.perm.{}", perm), true); - } - if !client.authorized_scopes.is_empty() { - let scopes: HashSet = client.authorized_scopes.iter().cloned().collect(); - bag.set("client.authorized_scopes", scopes); - } - if !client.authorized_audiences.is_empty() { - let auds: HashSet = client.authorized_audiences.iter().cloned().collect(); - bag.set("client.authorized_audiences", auds); - } - if !client.teams.is_empty() { - let teams: HashSet = client.teams.iter().cloned().collect(); - bag.set("client.teams", teams); - } - for (k, v) in &client.claims { - // Nested JSON claims flatten through the same walker `custom.*` - // uses — keeps semantics consistent across bridges. - crate::payload::walk(v, &format!("client.claim.{}", k), bag); - } -} - -/// Flatten a `WorkloadIdentity` into the bag under the given namespace -/// prefix — typically `"caller_workload"` or `"this_workload"`. Two -/// instances of this struct can coexist in `SecurityExtension` -/// (one inbound, one outbound) and they share the bag shape; the only -/// thing that varies is the namespace. -pub fn extract_workload(prefix: &str, w: &WorkloadIdentity, bag: &mut AttributeBag) { - if let Some(s) = &w.spiffe_id { - bag.set(format!("{}.spiffe_id", prefix), s.clone()); - } - if let Some(t) = &w.trust_domain { - bag.set(format!("{}.trust_domain", prefix), t.clone()); - } - if let Some(a) = &w.attestor { - bag.set(format!("{}.attestor", prefix), a.clone()); - } - if !w.selectors.is_empty() { - let selectors: HashSet = w.selectors.iter().cloned().collect(); - bag.set(format!("{}.selectors", prefix), selectors); - } - if let Some(id) = &w.client_id { - bag.set(format!("{}.client_id", prefix), id.clone()); - } - // `attested_at` intentionally omitted from the bag at v0 — APL - // doesn't carry DateTime as a bag value type, and policies that - // need it can opt into reading the typed extension directly. - let _ = &w.attested_at; -} - -/// Render the `ClientTrustLevel` enum as the bag string. Matches -/// `serde(rename_all = "snake_case")` on the type, with `Custom(s)` -/// rendering as `s` verbatim so policies can write -/// `client.trust_level == "partner-tier-A"`. The `_` arm exists -/// because `ClientTrustLevel` is `#[non_exhaustive]`; if a new -/// well-known variant lands upstream, this falls through to -/// "unknown" until we explicitly add a case — fail-loud rather than -/// silently picking one of the existing strings. -fn trust_level_str(level: &ClientTrustLevel) -> String { - match level { - ClientTrustLevel::FirstParty => "first_party".to_string(), - ClientTrustLevel::ThirdParty => "third_party".to_string(), - ClientTrustLevel::Internal => "internal".to_string(), - ClientTrustLevel::Custom(s) => s.clone(), - _ => "unknown".to_string(), - } -} - -fn subject_type_str(t: SubjectType) -> &'static str { - match t { - SubjectType::User => "user", - SubjectType::Agent => "agent", - SubjectType::Service => "service", - SubjectType::System => "system", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::{SubjectExtension, WorkloadIdentity}; - use std::collections::HashMap; - - fn alice() -> SecurityExtension { - SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice@corp.com".into()), - subject_type: Some(SubjectType::User), - roles: HashSet::from(["hr".to_string(), "manager".to_string()]), - permissions: HashSet::from(["view_ssn".to_string()]), - teams: HashSet::from(["compliance".to_string()]), - claims: HashMap::from([("iss".to_string(), "auth.corp".to_string())]), - }), - this_workload: Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/hr-tool".into()), - trust_domain: Some("corp.com".into()), - attestor: Some("spire-agent".into()), - selectors: vec!["k8s:ns:hr".into()], - client_id: Some("hr-tool".into()), - ..Default::default() - }), - auth_method: Some("jwt".into()), - ..Default::default() - } - } - - #[test] - fn subject_id_and_authenticated_marker() { - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - assert_eq!(bag.get_string("subject.id"), Some("alice@corp.com")); - assert_eq!(bag.get_bool("authenticated"), Some(true)); - assert_eq!(bag.get_string("subject.type"), Some("user")); - } - - #[test] - fn roles_become_individual_true_keys() { - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - // Each role → role. = true. DSL: `require(role.hr)`. - assert_eq!(bag.get_bool("role.hr"), Some(true)); - assert_eq!(bag.get_bool("role.manager"), Some(true)); - // A role Alice doesn't have is absent (not false — missing). - assert_eq!(bag.get_bool("role.finance"), None); - } - - #[test] - fn permissions_become_individual_true_keys() { - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - assert_eq!(bag.get_bool("perm.view_ssn"), Some(true)); - assert_eq!(bag.get_bool("perm.delete_user"), None); - } - - #[test] - fn teams_become_string_set() { - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - assert!(bag.set_contains("subject.teams", "compliance")); - assert!(!bag.set_contains("subject.teams", "engineering")); - } - - #[test] - fn claims_become_dotted_strings() { - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - assert_eq!(bag.get_string("claim.iss"), Some("auth.corp")); - } - - #[test] - fn this_workload_identity_keys() { - // `this_workload.*` namespace — our own attested identity. - // Distinct from the `agent.*` namespace of `AgentExtension` - // (session context) and the future `caller_workload.*` - // namespace for the inbound caller's SPIFFE identity. - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - assert_eq!(bag.get_string("this_workload.client_id"), Some("hr-tool")); - assert_eq!( - bag.get_string("this_workload.spiffe_id"), - Some("spiffe://corp.com/hr-tool") - ); - assert_eq!( - bag.get_string("this_workload.trust_domain"), - Some("corp.com") - ); - assert_eq!( - bag.get_string("this_workload.attestor"), - Some("spire-agent") - ); - assert!(bag.set_contains("this_workload.selectors", "k8s:ns:hr")); - } - - #[test] - fn auth_method_is_top_level() { - let mut bag = AttributeBag::new(); - extract_security(&alice(), &mut bag); - assert_eq!(bag.get_string("auth_method"), Some("jwt")); - } - - #[test] - fn labels_and_classification() { - let mut sec = SecurityExtension::default(); - sec.add_label("PII"); - sec.add_label("financial"); - sec.classification = Some("confidential".into()); - - let mut bag = AttributeBag::new(); - extract_security(&sec, &mut bag); - assert!(bag.set_contains("security.labels", "PII")); - assert!(bag.set_contains("security.labels", "financial")); - assert_eq!( - bag.get_string("security.classification"), - Some("confidential") - ); - } - - #[test] - fn no_subject_means_no_authenticated_marker() { - let sec = SecurityExtension::default(); // subject: None - let mut bag = AttributeBag::new(); - extract_security(&sec, &mut bag); - assert!(!bag.contains("authenticated")); - assert!(!bag.contains("subject.id")); - } - - #[test] - fn subject_without_id_is_not_authenticated() { - // A subject record exists but has no id — represents a recognized - // but unauthenticated principal (e.g. anonymous). The marker must - // not be set. - let sec = SecurityExtension { - subject: Some(SubjectExtension { - id: None, - roles: HashSet::from(["guest".to_string()]), - ..Default::default() - }), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_security(&sec, &mut bag); - assert!(!bag.contains("authenticated")); - // But role keys still land — role.guest is true. - assert_eq!(bag.get_bool("role.guest"), Some(true)); - } - - fn agent_client() -> ClientExtension { - ClientExtension { - client_id: "agent-app".into(), - client_name: Some("Agent App".into()), - trust_level: ClientTrustLevel::FirstParty, - authorized_scopes: vec!["read".into(), "write".into()], - authorized_audiences: vec!["https://api.example.com".into()], - roles: vec!["partner".into()], - permissions: vec!["call_tool".into()], - teams: vec!["acme".into()], - claims: HashMap::from([ - ("iss".to_string(), serde_json::json!("auth.example.com")), - ( - "scope_meta".to_string(), - serde_json::json!({ "max_calls_per_min": 60 }), - ), - ]), - } - } - - #[test] - fn client_required_id_and_trust_level() { - let mut bag = AttributeBag::new(); - extract_client(&agent_client(), &mut bag); - assert_eq!(bag.get_string("client.client_id"), Some("agent-app")); - assert_eq!(bag.get_string("client.client_name"), Some("Agent App")); - assert_eq!(bag.get_string("client.trust_level"), Some("first_party")); - } - - #[test] - fn client_roles_and_perms_become_individual_true_keys() { - // Symmetric with the subject pattern: `client.role.partner = true`. - // Lets policies write `require(client.role.partner)`. - let mut bag = AttributeBag::new(); - extract_client(&agent_client(), &mut bag); - assert_eq!(bag.get_bool("client.role.partner"), Some(true)); - assert_eq!(bag.get_bool("client.perm.call_tool"), Some(true)); - assert_eq!(bag.get_bool("client.role.nonexistent"), None); - } - - #[test] - fn client_scopes_audiences_teams_are_string_sets() { - let mut bag = AttributeBag::new(); - extract_client(&agent_client(), &mut bag); - assert!(bag.set_contains("client.authorized_scopes", "read")); - assert!(bag.set_contains("client.authorized_scopes", "write")); - assert!(bag.set_contains("client.authorized_audiences", "https://api.example.com",)); - assert!(bag.set_contains("client.teams", "acme")); - } - - #[test] - fn client_claims_flatten_nested_paths() { - // Claims are `HashMap` — nested objects must - // flatten through the same walker `custom.*` uses. Asserts the - // JSON-walker integration works for client just like custom. - let mut bag = AttributeBag::new(); - extract_client(&agent_client(), &mut bag); - assert_eq!(bag.get_string("client.claim.iss"), Some("auth.example.com")); - assert_eq!( - bag.get_int("client.claim.scope_meta.max_calls_per_min"), - Some(60), - ); - } - - #[test] - fn trust_level_custom_renders_verbatim() { - let mut client = agent_client(); - client.trust_level = ClientTrustLevel::Custom("partner-tier-A".into()); - let mut bag = AttributeBag::new(); - extract_client(&client, &mut bag); - assert_eq!(bag.get_string("client.trust_level"), Some("partner-tier-A")); - } - - fn workload_fixture() -> WorkloadIdentity { - WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/svc/foo".into()), - trust_domain: Some("corp.com".into()), - attestor: Some("spire-agent".into()), - selectors: vec!["k8s:ns:foo".into(), "k8s:sa:foo-sa".into()], - client_id: Some("foo-svc".into()), - ..Default::default() - } - } - - #[test] - fn extract_workload_populates_under_caller_prefix() { - // The same WorkloadIdentity feeds two distinct bag namespaces - // depending on which slot it lives in. This test pins - // `caller_workload.*`; the next pins `this_workload.*`. - let mut bag = AttributeBag::new(); - extract_workload("caller_workload", &workload_fixture(), &mut bag); - assert_eq!( - bag.get_string("caller_workload.spiffe_id"), - Some("spiffe://corp.com/svc/foo"), - ); - assert_eq!( - bag.get_string("caller_workload.trust_domain"), - Some("corp.com"), - ); - assert!(bag.set_contains("caller_workload.selectors", "k8s:ns:foo")); - // And the `this_workload.*` namespace must stay empty in this - // case — caller-prefix call must not leak into the other slot. - assert_eq!(bag.get_string("this_workload.spiffe_id"), None); - } - - #[test] - fn extract_workload_populates_under_this_prefix() { - let mut bag = AttributeBag::new(); - extract_workload("this_workload", &workload_fixture(), &mut bag); - assert_eq!( - bag.get_string("this_workload.spiffe_id"), - Some("spiffe://corp.com/svc/foo"), - ); - assert_eq!( - bag.get_string("this_workload.attestor"), - Some("spire-agent") - ); - assert_eq!(bag.get_string("caller_workload.spiffe_id"), None); - } - - #[test] - fn extract_security_populates_all_four_identity_namespaces() { - // Single fixture exercising subject + client + caller_workload + - // this_workload. Documents that one SecurityExtension can carry - // all four principals on a single request and the bridge fans - // them out into disjoint namespaces. - let sec = SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }), - client: Some(agent_client()), - caller_workload: Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/inbound".into()), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/gateway".into()), - ..Default::default() - }), - ..Default::default() - }; - let mut bag = AttributeBag::new(); - extract_security(&sec, &mut bag); - assert_eq!(bag.get_string("subject.id"), Some("alice")); - assert_eq!(bag.get_string("client.client_id"), Some("agent-app")); - assert_eq!( - bag.get_string("caller_workload.spiffe_id"), - Some("spiffe://corp.com/inbound"), - ); - assert_eq!( - bag.get_string("this_workload.spiffe_id"), - Some("spiffe://corp.com/gateway"), - ); - } -} diff --git a/crates/apl-cmf/tests/end_to_end.rs b/crates/apl-cmf/tests/end_to_end.rs deleted file mode 100644 index faf07ab7..00000000 --- a/crates/apl-cmf/tests/end_to_end.rs +++ /dev/null @@ -1,332 +0,0 @@ -// Location: ./crates/apl-cmf/tests/end_to_end.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Full vertical slice: cpex-core extensions → apl-cmf bridge → apl-core -// evaluator on a YAML-compiled route. If this test breaks, the whole -// stack is misaligned (extension shape, bag vocabulary, or compiler). - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use apl_cmf::BagBuilder; -use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, ElicitationInvoker, - NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, - PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, -}; -use async_trait::async_trait; -use cpex_core::extensions::{ - DelegationExtension, DelegationHop, SecurityExtension, SubjectExtension, SubjectType, - WorkloadIdentity, -}; -use serde_json::json; - -// `evaluate_route` takes `&Arc` / `&Arc` -// so the call paths inside apl-core can `Arc::clone` an owned, 'static reference -// into each spawned branch. All tests pass the same no-op stubs; wrap once. -fn pdp() -> Arc { - Arc::new(AllowPdp) -} -fn plugins() -> Arc { - Arc::new(NoPlugins) -} -fn delegations() -> Arc { - Arc::new(NoopDelegationInvoker) -} -fn elicitations() -> Arc { - Arc::new(NoopElicitationInvoker) -} - -// HR route. -const HR_ROUTE_YAML: &str = r#" -routes: - get_employee: - args: - employee_id: "str" - pre_invocation: - - "require(authenticated)" - - "delegation.depth > 2: deny" - result: - ssn: "str | redact(!perm.view_ssn)" - salary: "int | redact(!role.hr)" - employee_id: "str | mask(4)" -"#; - -// ---------- PDP / Plugin stubs ---------- - -struct AllowPdp; -#[async_trait] -impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }) - } -} - -struct NoPlugins; -#[async_trait] -impl PluginInvoker for NoPlugins { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - Err(PluginError::NotFound(name.into())) - } -} - -// ---------- Realistic extension fixtures ---------- - -fn alice_hr() -> SecurityExtension { - SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice@corp.com".into()), - subject_type: Some(SubjectType::User), - roles: HashSet::from(["hr".to_string()]), - permissions: HashSet::from(["view_ssn".to_string()]), - teams: HashSet::from(["compliance".to_string()]), - claims: HashMap::from([("iss".to_string(), "auth.corp".to_string())]), - }), - this_workload: Some(WorkloadIdentity { - client_id: Some("hr-tool".into()), - ..Default::default() - }), - auth_method: Some("jwt".into()), - ..Default::default() - } -} - -fn mallory_no_perm() -> SecurityExtension { - SecurityExtension { - subject: Some(SubjectExtension { - id: Some("mallory@corp.com".into()), - subject_type: Some(SubjectType::User), - ..Default::default() - }), - auth_method: Some("jwt".into()), - ..Default::default() - } -} - -fn shallow_delegation() -> DelegationExtension { - let mut del = DelegationExtension { - origin_subject_id: Some("alice@corp.com".into()), - ..Default::default() - }; - del.append_hop(DelegationHop { - subject_id: "alice@corp.com".into(), - ..Default::default() - }); - del -} - -fn deep_delegation() -> DelegationExtension { - let mut del = DelegationExtension::default(); - for hop in ["a", "b", "c"] { - del.append_hop(DelegationHop { - subject_id: hop.into(), - ..Default::default() - }); - } - del -} - -// ---------- Tests ---------- - -#[tokio::test] -async fn alice_full_route_through_cmf_bridge() { - let mut bag = BagBuilder::new() - .with_security(&alice_hr()) - .with_delegation(&shallow_delegation()) - .with_route_key("get_employee") - .build(); - - // Sanity-check the bag came out the way we expect. - assert_eq!(bag.get_bool("authenticated"), Some(true)); - assert_eq!(bag.get_bool("role.hr"), Some(true)); - assert_eq!(bag.get_bool("perm.view_ssn"), Some(true)); - assert_eq!(bag.get_int("delegation.depth"), Some(1)); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "123-45-6789" }), - json!({ - "ssn": "555-12-3456", - "salary": 95000, - "employee_id": "123-45-6789", - }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - let result = payload.result.as_ref().unwrap(); - // view_ssn=true and role.hr=true → both fields kept; employee_id masked. - assert_eq!(result["ssn"], json!("555-12-3456")); - assert_eq!(result["salary"], json!(95000)); - assert_eq!(result["employee_id"], json!("*******6789")); -} - -#[tokio::test] -async fn mallory_gets_both_fields_redacted_through_cmf_bridge() { - let mut bag = BagBuilder::new() - .with_security(&mallory_no_perm()) - .with_delegation(&shallow_delegation()) - .build(); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "555-44-3333" }), - json!({ - "ssn": "111-22-3333", - "salary": 80000, - "employee_id": "555-44-3333", - }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - let result = payload.result.as_ref().unwrap(); - // Neither role.hr nor perm.view_ssn populated → both redact()s fire. - assert_eq!(result["ssn"], json!("[REDACTED]")); - assert_eq!(result["salary"], json!("[REDACTED]")); - assert_eq!(result["employee_id"], json!("*******3333")); -} - -#[tokio::test] -async fn deep_delegation_denies_through_cmf_bridge() { - let mut bag = BagBuilder::new() - .with_security(&alice_hr()) - .with_delegation(&deep_delegation()) // depth = 3 - .build(); - - assert_eq!(bag.get_int("delegation.depth"), Some(3)); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "123-45-6789" }), - json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), - ); - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert!(matches!(r.decision, Decision::Deny { .. })); - // Result fields untouched — the result phase never ran. - assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("x")); -} - -#[tokio::test] -async fn args_attributes_flow_into_bag_for_policy_use() { - // Bridge args payload into the bag, then check that a policy - // predicate using `args.` evaluates against it. Uses an - // ad-hoc route, since the canonical HR route doesn't reference - // `args.*` in its policy block. - let yaml = r#" -routes: - guarded_route: - pre_invocation: - - "args.include_ssn == true: deny" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("guarded_route").unwrap(); - - let args = json!({ "include_ssn": true, "id": "abc" }); - let mut bag = BagBuilder::new() - .with_security(&alice_hr()) - .with_args(&args) - .build(); - assert_eq!(bag.get_bool("args.include_ssn"), Some(true)); - - let mut payload = RoutePayload::new(args); - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("pre_invocation"), - "got source {}", - rule_source - ); - }, - d => panic!("expected Deny on include_ssn, got {:?}", d), - } -} - -#[tokio::test] -async fn anonymous_user_denied_at_authenticated_check() { - // No security extension at all → no `authenticated` key in bag → - // `require(authenticated)` denies. - let mut bag = BagBuilder::new() - .with_delegation(&shallow_delegation()) - .build(); - assert!(!bag.contains("authenticated")); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "123-45-6789" }), - json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), - ); - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert!(matches!(r.decision, Decision::Deny { .. })); -} diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml deleted file mode 100644 index d5e2e401..00000000 --- a/crates/apl-core/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -# Location: ./crates/apl-core/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# APL core — predicate language, compiler, evaluator. -# Module structure follows docs/specs/apl-design.md §4. - -[package] -name = "apl-core" -description = "APL — Authorization Policy Language core (compiler + evaluator)" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[package.metadata.cargo-machete] -# serde is used through its derive macros (Serialize/Deserialize), which -# cargo-machete's source scan does not reliably detect. It is a real dependency. -ignored = ["serde"] - -[lib] -# Plain rlib; APL is consumed by other workspace crates (apl-cmf, apl-cpex) -# and does not need cdylib for FFI. - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -async-trait = { workspace = true } -regex = { workspace = true } -futures = { workspace = true } -cpex-orchestration = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } - -[lints] -workspace = true diff --git a/crates/apl-core/src/attribute_source.rs b/crates/apl-core/src/attribute_source.rs deleted file mode 100644 index f7cba2fe..00000000 --- a/crates/apl-core/src/attribute_source.rs +++ /dev/null @@ -1,131 +0,0 @@ -// Location: ./crates/apl-core/src/attribute_source.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Static attribute provisioning — the `data.*` bag namespace. -// -// `restrict` predicates (and policy predicates generally) read attributes -// that aren't carried by any token or fetched from anywhere: backend-free -// policy constants like tenant→region maps, per-agent model allow-lists, -// org defaults. Those come from a plain, operator-organized data tree that -// lands in the evaluation bag under `data.*`. -// -// This module is the pure contract: the `AttributeSource` trait (where a -// tree comes from) and the `AttributeTree` value (what it is). The default -// file-backed source and the bag flattening live at the outer layers -// (apl-cpex / apl-cmf) — apl-core stays free of I/O and config deps. - -use serde_json::Value; -use thiserror::Error; - -/// The static attribute tree — the whole `data:` document, a plain nested -/// value the operator organizes however they like. It carries *literal -/// values only*: no conditionals, no computed fields (that guardrail is -/// structural — there is no syntax in a data tree to express logic). -/// -/// Flattened into the bag under `data.*` by the bag builder (apl-cmf): -/// `{ org: { default_region: us } }` → `data.org.default_region = "us"`. -#[derive(Debug, Clone, PartialEq)] -pub struct AttributeTree(Value); - -impl AttributeTree { - /// Wrap a loaded `data` document. Expected to be a JSON/YAML object; - /// a non-object is tolerated but flattens to nothing useful. - pub fn new(value: Value) -> Self { - Self(value) - } - - /// The empty tree — no static attributes. The default when no source - /// is configured. - pub fn empty() -> Self { - Self(Value::Object(serde_json::Map::new())) - } - - /// Borrow the underlying value (the bag builder walks this). - pub fn as_value(&self) -> &Value { - &self.0 - } - - /// True when the tree holds nothing (no keys). - pub fn is_empty(&self) -> bool { - match &self.0 { - Value::Object(m) => m.is_empty(), - Value::Null => true, - _ => false, - } - } -} - -impl Default for AttributeTree { - fn default() -> Self { - Self::empty() - } -} - -/// Where the `data.*` tree comes from — a **trait object injected at -/// construction**, not a CPEX hook-plugin. The host implements it over a -/// file, etcd, Postgres, a k8s ConfigMap, etc., and hands the object to -/// the runtime at startup. -/// -/// `load` is **synchronous and one-shot**: it runs once at startup, never -/// on the request hot path, so blocking the init thread on file/network -/// I/O is fine — and it lets the (synchronous) runtime setup call it -/// directly, no async plumbing. A source that fronts a genuinely async -/// store can block on its own runtime at this edge. -/// -/// v1 is **snapshot only**. Hot-reload (a `watch` that streams fresh -/// trees) is deferred — that is the one genuinely-async concern, and its -/// shape (stream vs channel vs callback) is best decided when it's built, -/// not stubbed now. A lazy per-key `resolve(path)` for huge stores is -/// likewise deferred. -pub trait AttributeSource: Send + Sync { - /// Load the full attribute tree (a snapshot). - fn load(&self) -> Result; -} - -/// Why an attribute source failed to produce a tree. Loading is a -/// startup/config concern, so these surface as configuration errors at -/// the runtime boundary (fail-fast, not per-request). -#[derive(Debug, Error)] -pub enum AttributeError { - /// The backing store could not be read (missing file, I/O error, - /// unreachable etcd, …). - #[error("attribute source load failed: {0}")] - Load(String), - - /// The loaded bytes were not valid for the source's format. - #[error("attribute source parse failed: {0}")] - Parse(String), - - /// Two inputs set the *same* leaf path to different values — a real - /// conflict the source refuses to silently resolve (fail-fast merge). - #[error("attribute conflict at `{path}`: `{existing}` vs `{incoming}`")] - Conflict { - path: String, - existing: String, - incoming: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn empty_tree_is_empty() { - assert!(AttributeTree::empty().is_empty()); - assert!(AttributeTree::default().is_empty()); - } - - #[test] - fn populated_tree_is_not_empty() { - let t = AttributeTree::new(json!({ "org": { "default_region": "us" } })); - assert!(!t.is_empty()); - assert_eq!( - t.as_value().pointer("/org/default_region"), - Some(&json!("us")) - ); - } -} diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs deleted file mode 100644 index 98067a6f..00000000 --- a/crates/apl-core/src/attributes.rs +++ /dev/null @@ -1,274 +0,0 @@ -// Location: ./crates/apl-core/src/attributes.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// AttributeBag — flat namespace for policy evaluation. -// -// The DSL evaluates predicates against a flat bag of named, typed values. -// Each attribute source (cpex-core extensions, route args, session context, -// custom plugin namespaces) drops keys into the bag through the -// `AttributeExtractor` trait. -// -// A flat bag (rather than nested object access) means the evaluator never -// has to know which extension a key came from — it just queries by name. -// New attribute sources are additive: implement `AttributeExtractor` for -// them and the evaluator picks them up unchanged. -// -// Mapping from cpex-core extensions into the bag lives in `apl-cmf`, not -// here. - -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; - -/// A single attribute value the evaluator can compare against. -/// -/// The five variants cover every shape the DSL needs: -/// `Bool` for `authenticated` / `role.*` / `perm.*`, -/// `Int` for counts and depths, -/// `Float` for confidences and ages, -/// `String` for identifiers, -/// `StringSet` for set-membership operators (`contains`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum AttributeValue { - Bool(bool), - Int(i64), - Float(f64), - String(String), - StringSet(HashSet), -} - -impl From for AttributeValue { - fn from(v: bool) -> Self { - AttributeValue::Bool(v) - } -} -impl From for AttributeValue { - fn from(v: i64) -> Self { - AttributeValue::Int(v) - } -} -impl From for AttributeValue { - fn from(v: f64) -> Self { - AttributeValue::Float(v) - } -} -impl From<&str> for AttributeValue { - fn from(v: &str) -> Self { - AttributeValue::String(v.to_string()) - } -} -impl From for AttributeValue { - fn from(v: String) -> Self { - AttributeValue::String(v) - } -} -impl From> for AttributeValue { - fn from(v: HashSet) -> Self { - AttributeValue::StringSet(v) - } -} - -/// Flat key→value namespace consumed by the evaluator. -/// -/// Populate via `set()` and/or `AttributeExtractor::extract()`; query via -/// the typed `get_*` methods. Once handed to the evaluator the bag is -/// read-only by convention (not enforced — `&mut` borrows are how you -/// build it up in the first place). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AttributeBag { - attrs: HashMap, -} - -impl AttributeBag { - pub fn new() -> Self { - Self { - attrs: HashMap::new(), - } - } - - pub fn set(&mut self, key: impl Into, value: impl Into) { - self.attrs.insert(key.into(), value.into()); - } - - pub fn get(&self, key: &str) -> Option<&AttributeValue> { - self.attrs.get(key) - } - - pub fn contains(&self, key: &str) -> bool { - self.attrs.contains_key(key) - } - - pub fn get_bool(&self, key: &str) -> Option { - match self.get(key) { - Some(AttributeValue::Bool(v)) => Some(*v), - _ => None, - } - } - - pub fn get_int(&self, key: &str) -> Option { - match self.get(key) { - Some(AttributeValue::Int(v)) => Some(*v), - _ => None, - } - } - - pub fn get_float(&self, key: &str) -> Option { - match self.get(key) { - Some(AttributeValue::Float(v)) => Some(*v), - // Promote int → float so `depth > 2.5`-style predicates work - // when depth is stored as Int. - Some(AttributeValue::Int(v)) => Some(*v as f64), - _ => None, - } - } - - pub fn get_string(&self, key: &str) -> Option<&str> { - match self.get(key) { - Some(AttributeValue::String(v)) => Some(v.as_str()), - _ => None, - } - } - - pub fn get_string_set(&self, key: &str) -> Option<&HashSet> { - match self.get(key) { - Some(AttributeValue::StringSet(v)) => Some(v), - _ => None, - } - } - - /// DSL ` contains ` — false if the key is missing or not a set. - pub fn set_contains(&self, key: &str, value: &str) -> bool { - self.get_string_set(key) - .map(|set| set.contains(value)) - .unwrap_or(false) - } - - /// Resolve an attribute path to its concrete flat key, expanding any - /// `[inner]` interpolation groups. Each `[inner]` looks - /// `inner` up in this bag and substitutes `.` + its scalar value: - /// `data.tenants[subject.tenant].data_region` with `subject.tenant = - /// "acme-eu"` → `data.tenants.acme-eu.data_region`. The common - /// bracket-free key is returned borrowed (no allocation). - /// - /// Returns `None` when any `inner` key is missing or not a scalar — the - /// caller treats that as an absent attribute, so a lookup keyed on an - /// unknown request value fails to match rather than hitting a - /// half-substituted key. Shared by predicate evaluation and - /// `restrict` field references. - pub fn resolve_key<'a>(&self, key: &'a str) -> Option> { - if !key.contains('[') { - return Some(std::borrow::Cow::Borrowed(key)); - } - let mut out = String::with_capacity(key.len()); - let mut rest = key; - while let Some(open) = rest.find('[') { - out.push_str(&rest[..open]); - let after = &rest[open + 1..]; - // The lexer guarantees a matching `]`; `?` is defensive. - let close = after.find(']')?; - let inner = after[..close].trim(); - out.push('.'); - out.push_str(&self.scalar_as_string(inner)?); - rest = &after[close + 1..]; - } - out.push_str(rest); - Some(std::borrow::Cow::Owned(out)) - } - - /// The scalar at `key`, stringified for use as a path segment. Numbers - /// and bools coerce to their text form (a tenant id may be numeric); a - /// `StringSet` cannot index a path, so it yields `None`. - fn scalar_as_string(&self, key: &str) -> Option { - match self.get(key)? { - AttributeValue::String(s) => Some(s.clone()), - AttributeValue::Int(i) => Some(i.to_string()), - AttributeValue::Bool(b) => Some(b.to_string()), - AttributeValue::Float(f) => Some(f.to_string()), - AttributeValue::StringSet(_) => None, - } - } - - pub fn len(&self) -> usize { - self.attrs.len() - } - - pub fn is_empty(&self) -> bool { - self.attrs.is_empty() - } - - pub fn iter(&self) -> impl Iterator { - self.attrs.iter().map(|(k, v)| (k.as_str(), v)) - } -} - -/// Source of attributes. Implementors drop keys into the bag under a -/// consistent namespace prefix: -/// -/// - cpex-core `SecurityExtension.subject` → `subject.*`, `role.*`, `perm.*` -/// - cpex-core `SecurityExtension.client` → `client.*` -/// - cpex-core `DelegationExtension` → `delegation.*`, `delegated` -/// - Route args → `args.*` -/// - Session context → `session.*` -/// -/// Implementations for the cpex-core extensions live in `apl-cmf`, not here. -pub trait AttributeExtractor { - fn extract(&self, bag: &mut AttributeBag); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn basic_bag() { - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("delegation.depth", 2i64); - bag.set("subject.id", "alice@corp.com"); - bag.set("intent.confidence", 0.92f64); - - assert_eq!(bag.get_bool("authenticated"), Some(true)); - assert_eq!(bag.get_int("delegation.depth"), Some(2)); - assert_eq!(bag.get_string("subject.id"), Some("alice@corp.com")); - assert_eq!(bag.get_float("intent.confidence"), Some(0.92)); - } - - #[test] - fn int_to_float_promotion() { - let mut bag = AttributeBag::new(); - bag.set("delegation.depth", 2i64); - assert_eq!(bag.get_float("delegation.depth"), Some(2.0)); - } - - #[test] - fn string_set_contains() { - let mut bag = AttributeBag::new(); - bag.set( - "session.labels", - HashSet::from(["PII".to_string(), "financial".to_string()]), - ); - - assert!(bag.set_contains("session.labels", "PII")); - assert!(bag.set_contains("session.labels", "financial")); - assert!(!bag.set_contains("session.labels", "PHI")); - } - - #[test] - fn missing_keys() { - let bag = AttributeBag::new(); - assert_eq!(bag.get_bool("nonexistent"), None); - assert_eq!(bag.get_int("nonexistent"), None); - assert!(!bag.set_contains("nonexistent", "value")); - } - - #[test] - fn type_mismatch_returns_none() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - // Stored as String; asking for Bool returns None, not a coerced value. - assert_eq!(bag.get_bool("subject.id"), None); - assert_eq!(bag.get_int("subject.id"), None); - } -} diff --git a/crates/apl-core/src/constraint.rs b/crates/apl-core/src/constraint.rs deleted file mode 100644 index 1e1a2036..00000000 --- a/crates/apl-core/src/constraint.rs +++ /dev/null @@ -1,461 +0,0 @@ -// Location: ./crates/apl-core/src/constraint.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Backend candidate-constraint IR for the `restrict` effect. -// -// `restrict` narrows the set of backends the host's router/load-balancer -// may select from — it never picks a backend. It normally does not allow/deny the -// request either; the one exception is fail-closed integrity — an -// unresolvable `deny_models` reference denies, since a deny-list cannot fail -// open (see `RestrictResolveError`). It is an accumulating -// effect in the same family as `taint`: the evaluator collects the -// constraints a route emits into `RouteDecision.constraints`, and the -// bridge (apl-cpex) folds them into a typed `CandidateConstraintExtension` -// the host reads off the returned `Extensions`. This type is the *authoring* -// IR — one constraint per `restrict` effect. It stays pure-data with no -// cpex-core dependency, matching the rest of `rules.rs`; the fold + the -// wire/extension type live at the bridge layer. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::attributes::{AttributeBag, AttributeValue}; - -/// One backend-eligibility constraint emitted by a `restrict` effect. -/// -/// Every field describes a requirement a candidate backend must satisfy; -/// the host evaluates them against each backend's labels. The shape is a -/// deliberately **simple set of typed fields plus a `custom` label map** — -/// not a general predicate language — so the host only has to run a small -/// label matcher (set membership, glob, tier compare, equality), not a -/// predicate interpreter. -/// -/// All fields are optional/empty by default; an all-empty -/// `CandidateConstraint` places no restriction (see [`Self::is_empty`]). -/// Constraints are **monotone**: combining two of them (the bridge's fold) -/// can only ever shrink the eligible set (allow-sets intersect, deny-sets -/// and `custom` union), never widen it. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct CandidateConstraint { - /// Candidate `model` label must be in this set (glob-matched, e.g. - /// `"anthropic/claude-sonnet-*"`). `None` = no model allow-list. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_models: Option>, - - /// Candidate `model` label must NOT match any of these (glob-matched). - /// Empty = no model deny-list. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub deny_models: Vec, - - /// Candidate `region` label must be in this set (equality). `None` = - /// no region constraint. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_regions: Option>, - - /// Candidate `site` label must be in this set (equality). `None` = no - /// site constraint. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_sites: Option>, - - /// Candidate `cost_tier` label must be ≤ this tier. The *ordering* of - /// tiers is defined on the host (the matcher), so this stays a plain - /// label here — CPEX passes it through without needing to know the - /// order. `None` = no tier ceiling. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_cost_tier: Option, - - /// Arbitrary backend labels the candidate must carry, matched by plain - /// equality (k8s `nodeSelector` semantics). The escape hatch for - /// backend attributes without a typed field above. Empty = none. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub custom: BTreeMap, - - /// What the host should do if the constraint prunes every candidate. - /// Fail-closed by default (see [`OnEmpty`]). - #[serde(default)] - pub on_empty: OnEmpty, -} - -impl CandidateConstraint { - /// True when this constraint restricts nothing — every field is unset. - /// The evaluator skips emitting an all-empty constraint, and it's a - /// useful guard in tests. - pub fn is_empty(&self) -> bool { - self.allow_models.is_none() - && self.deny_models.is_empty() - && self.allow_regions.is_none() - && self.allow_sites.is_none() - && self.max_cost_tier.is_none() - && self.custom.is_empty() - } -} - -/// What the host does when a constraint leaves no eligible backend. -/// -/// CPEX cannot decide this itself — only the router knows which backends -/// are actually reachable/healthy at selection time — so the choice rides -/// out with the constraint. The default is fail-closed. -/// -/// Mirrors `cpex_core::extensions::OnEmpty` (the bridge maps between them); -/// kept here so apl-core stays free of a cpex-core dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OnEmpty { - /// Reject the request (fail-closed). Correct for hard constraints like - /// data sovereignty — never silently escape the region. - #[default] - Deny, - /// Fall back to the unconstrained candidate set. Explicit opt-in for - /// "prefer, but don't fail" cases. - Fallback, -} - -/// A `restrict` string-set field: either a literal set or a `data.*`/bag -/// reference resolved against the request at eval time. The -/// YAML shape disambiguates — a sequence is a literal, a bare scalar is a -/// reference: -/// -/// ```yaml -/// allow_models: [vllm/*, anthropic/*] # Literal -/// allow_models: data.agents[subject.id].allowed_models # Ref -/// ``` -/// -/// A reference lets one rule serve every caller — the per-agent / -/// per-tenant set lives in the [static attribute tree][crate::AttributeTree], -/// not hard-coded in the route. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StringSetSpec { - /// A `data.*` / bag path resolved to a set at eval time. A bare scalar - /// in YAML (`allow_models: data.agents[subject.id].allowed_models`). - Ref(String), - /// A literal set. A YAML sequence (`allow_models: [vllm/*]`). - Literal(Vec), -} - -impl StringSetSpec { - /// Resolve to a concrete set, or `None` if a reference could not be - /// resolved. A `Literal` always resolves (`Some`). A `Ref` looks its - /// path up in the request bag (expanding `[...]` interpolation) and - /// reads the `StringSet`/`String` there; a **missing key or wrong-shape - /// value** yields `None`. A legitimately empty `StringSet` still resolves - /// to `Some([])` — "resolved to nothing" is distinct from "couldn't - /// resolve," and the caller ([`RestrictSpec::resolve`]) decides what each - /// means per field. - fn resolve(&self, bag: &AttributeBag) -> Option> { - match self { - StringSetSpec::Literal(v) => Some(v.clone()), - StringSetSpec::Ref(path) => { - // Interpolation itself failed (e.g. the `[...]` key is absent). - let key = bag.resolve_key(path)?; - match bag.get(&key) { - Some(AttributeValue::StringSet(s)) => { - let mut v: Vec = s.iter().cloned().collect(); - v.sort(); - Some(v) - }, - Some(AttributeValue::String(s)) => Some(vec![s.clone()]), - // Absent, or present but not a set/string — the reference - // did not resolve to a usable set. - _ => None, - } - }, - } - } -} - -/// Why a `restrict` effect could not be resolved against the request bag. -/// -/// Today only an unresolvable `deny_models` reference produces this — a -/// deny-list whose `data.*` source is missing or the wrong shape. An -/// allow-list fails closed by *shrinking* to the empty set (`Some([])`), a -/// value it can represent; a deny-list would have to *grow* to "deny -/// everything," which the empty vector cannot express. So an unresolvable -/// deny reference is treated as an integrity failure — the evaluator denies -/// the request rather than route it with an unknown deny-list. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -#[error( - "restrict: `{field}` reference `{path}` did not resolve to a set — denying \ - (a deny-list cannot fail open)" -)] -pub struct RestrictResolveError { - /// The `restrict` field that failed to resolve (e.g. `"deny_models"`). - pub field: &'static str, - /// The `data.*` reference path that did not resolve. - pub path: String, -} - -/// The authoring form of a `restrict` effect. Same fields as -/// [`CandidateConstraint`], except the string-set fields may be a literal -/// **or** a `data.*` reference ([`StringSetSpec`]). The parser produces -/// this; the evaluator calls [`Self::resolve`] to turn it into a literal -/// `CandidateConstraint` before accumulating — references never reach the -/// fold or the wire. (`max_cost_tier` and `custom` are literal-only in v1.) -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct RestrictSpec { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_models: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deny_models: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_regions: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_sites: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_cost_tier: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub custom: BTreeMap, - #[serde(default)] - pub on_empty: OnEmpty, -} - -impl RestrictSpec { - /// True when no constraint field is set. The parser rejects an empty - /// `restrict:` on this (`on_empty` alone constrains nothing). - pub fn is_empty(&self) -> bool { - self.allow_models.is_none() - && self.deny_models.is_none() - && self.allow_regions.is_none() - && self.allow_sites.is_none() - && self.max_cost_tier.is_none() - && self.custom.is_empty() - } - - /// Resolve every `data.*` reference against the request bag, producing - /// the literal `CandidateConstraint` the evaluator accumulates — or a - /// [`RestrictResolveError`] if a deny-list reference could not be - /// resolved. - /// - /// The two list kinds fail closed in opposite directions: - /// * **Allow-lists** (`allow_models` / `allow_regions` / `allow_sites`): - /// an unresolvable reference resolves to the empty set (`Some([])`), - /// which qualifies no candidate. The host's `on_empty` then decides. - /// * **Deny-lists** (`deny_models`): an unresolvable reference is an - /// integrity failure — a deny-list has no "empty = deny everything" - /// value, so we cannot safely route with an unknown one. Returns `Err` - /// and the evaluator denies the request. - pub fn resolve(&self, bag: &AttributeBag) -> Result { - // Allow-lists fail closed by shrinking to empty — `None` (unresolved) - // collapses to `Some([])`, preserving the pre-reference behavior. - let allow_models = self - .allow_models - .as_ref() - .map(|s| s.resolve(bag).unwrap_or_default()); - let allow_regions = self - .allow_regions - .as_ref() - .map(|s| s.resolve(bag).unwrap_or_default()); - let allow_sites = self - .allow_sites - .as_ref() - .map(|s| s.resolve(bag).unwrap_or_default()); - - // A deny-list that references data cannot fail open: an unresolvable - // reference denies the request. A literal deny-list always resolves. - let deny_models = match self.deny_models.as_ref() { - None => Vec::new(), - Some(spec) => match spec.resolve(bag) { - Some(v) => v, - None => { - let path = match spec { - StringSetSpec::Ref(p) => p.clone(), - // A literal always resolves to `Some`, so this is - // unreachable — kept total for safety. - StringSetSpec::Literal(_) => String::new(), - }; - return Err(RestrictResolveError { - field: "deny_models", - path, - }); - }, - }, - }; - - Ok(CandidateConstraint { - allow_models, - deny_models, - allow_regions, - allow_sites, - max_cost_tier: self.max_cost_tier.clone(), - custom: self.custom.clone(), - on_empty: self.on_empty, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::attributes::AttributeBag; - - // `is_empty()` is hand-written on both `CandidateConstraint` and - // `RestrictSpec` (and mirrored again on the cpex-core extension). A field - // that's added to the struct but forgotten in `is_empty()` would make a - // real restriction look empty and get silently dropped. The exhaustive - // destructures below (no `..`) fail to compile if a field is added - // without updating these tests, and the per-field asserts force each - // constraint-bearing field to count toward non-emptiness. - - #[test] - fn candidate_constraint_is_empty_covers_every_field() { - // No `..`: adding a field breaks this until it's accounted for. - let CandidateConstraint { - allow_models, - deny_models, - allow_regions, - allow_sites, - max_cost_tier, - custom, - on_empty: _, // `on_empty` alone never makes a constraint non-empty - } = CandidateConstraint::default(); - assert!(allow_models.is_none()); - assert!(deny_models.is_empty()); - assert!(allow_regions.is_none()); - assert!(allow_sites.is_none()); - assert!(max_cost_tier.is_none()); - assert!(custom.is_empty()); - assert!(CandidateConstraint::default().is_empty()); - - // Setting any single constraint-bearing field flips `is_empty()`. - let each: [CandidateConstraint; 6] = [ - CandidateConstraint { - allow_models: Some(vec![]), - ..Default::default() - }, - CandidateConstraint { - deny_models: vec!["x".into()], - ..Default::default() - }, - CandidateConstraint { - allow_regions: Some(vec![]), - ..Default::default() - }, - CandidateConstraint { - allow_sites: Some(vec![]), - ..Default::default() - }, - CandidateConstraint { - max_cost_tier: Some("cheap".into()), - ..Default::default() - }, - CandidateConstraint { - custom: [("k".to_string(), "v".to_string())].into(), - ..Default::default() - }, - ]; - for c in each { - assert!(!c.is_empty(), "field should count toward non-empty: {c:?}"); - } - // `on_empty` alone does not. - assert!(CandidateConstraint { - on_empty: OnEmpty::Fallback, - ..Default::default() - } - .is_empty()); - } - - #[test] - fn restrict_spec_is_empty_covers_every_field() { - let RestrictSpec { - allow_models, - deny_models, - allow_regions, - allow_sites, - max_cost_tier, - custom, - on_empty: _, - } = RestrictSpec::default(); - assert!(allow_models.is_none()); - assert!(deny_models.is_none()); - assert!(allow_regions.is_none()); - assert!(allow_sites.is_none()); - assert!(max_cost_tier.is_none()); - assert!(custom.is_empty()); - assert!(RestrictSpec::default().is_empty()); - - let lit = |s: &str| Some(StringSetSpec::Literal(vec![s.to_string()])); - let each: [RestrictSpec; 6] = [ - RestrictSpec { - allow_models: lit("m"), - ..Default::default() - }, - RestrictSpec { - deny_models: lit("m"), - ..Default::default() - }, - RestrictSpec { - allow_regions: lit("eu"), - ..Default::default() - }, - RestrictSpec { - allow_sites: lit("s"), - ..Default::default() - }, - RestrictSpec { - max_cost_tier: Some("cheap".into()), - ..Default::default() - }, - RestrictSpec { - custom: [("k".to_string(), "v".to_string())].into(), - ..Default::default() - }, - ]; - for s in each { - assert!(!s.is_empty(), "field should count toward non-empty: {s:?}"); - } - assert!(RestrictSpec { - on_empty: OnEmpty::Fallback, - ..Default::default() - } - .is_empty()); - } - - #[test] - fn nonempty_spec_resolves_to_nonempty_constraint() { - // Cross-struct parity: a spec non-empty on any single field must - // resolve to a constraint that is ALSO non-empty — otherwise the - // evaluator's `if !constraint.is_empty()` guard would silently drop a - // real restriction. Catches a field the two `is_empty()`s (or - // `resolve()`) disagree on. - let bag = AttributeBag::new(); - let lit = |s: &str| Some(StringSetSpec::Literal(vec![s.to_string()])); - let specs: [RestrictSpec; 6] = [ - RestrictSpec { - allow_models: lit("m"), - ..Default::default() - }, - RestrictSpec { - deny_models: lit("m"), - ..Default::default() - }, - RestrictSpec { - allow_regions: lit("eu"), - ..Default::default() - }, - RestrictSpec { - allow_sites: lit("s"), - ..Default::default() - }, - RestrictSpec { - max_cost_tier: Some("cheap".into()), - ..Default::default() - }, - RestrictSpec { - custom: [("k".to_string(), "v".to_string())].into(), - ..Default::default() - }, - ]; - for spec in specs { - assert!(!spec.is_empty(), "spec should be non-empty: {spec:?}"); - let c = spec.resolve(&bag).expect("literal spec always resolves"); - assert!( - !c.is_empty(), - "non-empty spec resolved to a dropped constraint: {spec:?}" - ); - } - } -} diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs deleted file mode 100644 index 9313acab..00000000 --- a/crates/apl-core/src/evaluator.rs +++ /dev/null @@ -1,4272 +0,0 @@ -// Location: ./crates/apl-core/src/evaluator.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// APL evaluator — walks the IR against an AttributeBag and returns a Decision. -// -// The evaluator is sync and infallible by design. Missing attributes resolve -// to `false`; operator type mismatches resolve to `false`. -// The host drives the four phases separately by calling `evaluate_rules` once -// per declared phase — phase orchestration lives in `apl-cpex`. -// -// Semantics: -// - operators, actions, require -// - native fast-path, sync inside async outer - -use std::sync::Arc; - -use crate::attributes::{AttributeBag, AttributeValue}; -use crate::pipeline::{Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck}; -use crate::rules::{CompareOp, Condition, Effect, Expression, Literal, Rule}; -use crate::step::{PdpResolver, PluginInvocation, PluginInvoker}; - -/// Outcome of evaluating a phase's rule list. -#[derive(Debug, Clone, PartialEq)] -pub enum Decision { - /// No `deny` rule fired. Pipeline proceeds. - Allow, - /// A `deny` rule fired. Pipeline halts. - Deny { - reason: Option, - /// `Rule.source` of the rule that produced the deny — for audit logs. - rule_source: String, - }, -} - -/// Evaluate a phase's rules against the bag. -/// -/// Spec semantics: -/// - First `deny` halts; subsequent rules / effects don't run. -/// - `allow` effects *do not* short-circuit — evaluation continues to -/// the next effect (then to the next rule). -/// - If no rule denies, the phase resolves to `Decision::Allow`. -/// -/// Sync fast path — only handles control effects (`Allow` / `Deny`). -/// Rules containing `Plugin` / `Delegate` / `Taint` effects must go -/// through [`evaluate_steps`] instead, which has the async invoker -/// traits wired up. This function silently skips non-control effects -/// so a rule list mixed with `Plugin` still terminates cleanly on a -/// later `Deny` — but the side effects don't fire. Caller's job to -/// pick the right entry point for the effects in the rules. -pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision { - for rule in rules { - if !eval_expression(&rule.condition, bag) { - continue; - } - for effect in &rule.effects { - match effect { - Effect::Allow => continue, - Effect::Deny { reason, code } => { - // `code` override on the effect takes precedence - // over the auto-generated rule source position, - // so author-stable categories survive YAML edits. - let rule_source = code.clone().unwrap_or_else(|| rule.source.clone()); - return Decision::Deny { - reason: reason.clone(), - rule_source, - }; - }, - // Plugin / Delegate / Taint require the async step - // path; ignore here. See doc comment above. - _ => continue, - } - } - } - Decision::Allow -} - -fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool { - match expr { - Expression::Condition(c) => eval_condition(c, bag), - Expression::And(parts) => parts.iter().all(|e| eval_expression(e, bag)), - Expression::Or(parts) => parts.iter().any(|e| eval_expression(e, bag)), - Expression::Not(inner) => !eval_expression(inner, bag), - Expression::Always => true, - } -} - -fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool { - match cond { - // An unresolvable interpolated path (a missing request value) is - // treated as an absent key: `IsTrue`/`Exists`/`Comparison` are - // false, but `IsFalse` is true (absent is falsy — keeps - // `require(...)` fail-closed when the keyed lookup can't resolve). - Condition::IsTrue { key } => bag - .resolve_key(key) - .map(|k| bag.get_bool(&k).unwrap_or(false)) - .unwrap_or(false), - Condition::IsFalse { key } => bag - .resolve_key(key) - .map(|k| !bag.get_bool(&k).unwrap_or(false)) - .unwrap_or(true), - Condition::Exists { key } => bag - .resolve_key(key) - .map(|k| bag.contains(&k)) - .unwrap_or(false), - Condition::Comparison { key, op, value } => match bag.resolve_key(key) { - Some(k) => eval_comparison(&k, *op, value, bag), - None => false, - }, - Condition::InSet { - value_key, - set_key, - negate, - } => { - let in_set = match bag.resolve_key(value_key).zip(bag.resolve_key(set_key)) { - Some((vk, sk)) => match (bag.get_string(&vk), bag.get_string_set(&sk)) { - (Some(s), Some(set)) => set.contains(s), - _ => false, // missing key or wrong type → not in set - }, - None => false, // an interpolated key didn't resolve - }; - if *negate { - !in_set - } else { - in_set - } - }, - } -} - -fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) -> bool { - let attr = match bag.get(key) { - Some(v) => v, - None => return false, // missing → false - }; - - match op { - CompareOp::Contains => match (attr, lit) { - (AttributeValue::StringSet(_), Literal::String(s)) => bag.set_contains(key, s), - _ => false, - }, - CompareOp::Eq => values_eq(attr, lit), - CompareOp::NotEq => !values_eq(attr, lit), - CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => { - numeric_compare(attr, lit, op) - }, - } -} - -fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool { - match (attr, lit) { - (AttributeValue::Bool(a), Literal::Bool(b)) => a == b, - (AttributeValue::Int(a), Literal::Int(b)) => a == b, - (AttributeValue::Float(a), Literal::Float(b)) => a == b, - (AttributeValue::String(a), Literal::String(b)) => a == b, - // Int↔Float promotion for equality (matches AttributeBag::get_float). - (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64) == *b, - (AttributeValue::Float(a), Literal::Int(b)) => *a == (*b as f64), - _ => false, - } -} - -fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool { - // Coerce both operands to f64 for the order comparison. Numeric-looking - // strings are coerced — LLM tool arguments routinely arrive as strings - // (e.g. `"amount": "25000"`), and a policy author writing - // `args.amount > 10000` plainly means a numeric comparison. A string - // that doesn't parse as a number is genuinely non-numeric → false - // (order operators don't apply). - let a = match coerce_f64_attr(attr) { - Some(a) => a, - None => return false, - }; - let b = match coerce_f64_lit(lit) { - Some(b) => b, - None => return false, - }; - match op { - CompareOp::Gt => a > b, - CompareOp::GtEq => a >= b, - CompareOp::Lt => a < b, - CompareOp::LtEq => a <= b, - _ => unreachable!("numeric_compare called with non-numeric op"), - } -} - -/// Coerce a bag attribute to `f64` for an order comparison: numbers pass -/// through; a string is parsed (numeric-looking strings only); anything -/// else is non-numeric. -fn coerce_f64_attr(attr: &AttributeValue) -> Option { - match attr { - AttributeValue::Int(a) => Some(*a as f64), - AttributeValue::Float(a) => Some(*a), - AttributeValue::String(s) => s.trim().parse::().ok(), - _ => None, - } -} - -/// Coerce a literal to `f64` for an order comparison. Same rules as -/// [`coerce_f64_attr`] so `args.x > "10"` works symmetrically. -fn coerce_f64_lit(lit: &Literal) -> Option { - match lit { - Literal::Int(b) => Some(*b as f64), - Literal::Float(b) => Some(*b), - Literal::String(s) => s.trim().parse::().ok(), - _ => None, - } -} - -/// Heuristic: does `s` look like a bag attribute reference (e.g. -/// `claim.manager`, `user.sub`) rather than a literal identity (e.g. -/// `alice@corp.com`, a bare username)? Used to decide whether an -/// unresolved elicitation `from` should fail closed (an attribute that -/// didn't resolve) or pass through as a literal. -/// -/// A reference is a dotted path of identifier characters only — starts -/// with a letter and contains only `[A-Za-z0-9_.]` with at least one dot. -/// Literals like emails (contain `@`) or display names (contain spaces) -/// are excluded, so they still pass through verbatim. -fn looks_like_attribute_ref(s: &str) -> bool { - s.contains('.') - && s.starts_with(|c: char| c.is_ascii_alphabetic()) - && s.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') -} - -/// Walk an Effect list against the bag, dispatching PDP calls via `pdp` -/// and plugin invocations via `plugins`. Returns the phase's overall -/// decision. -/// -/// Semantics: -/// - `Effect::When` — evaluate the condition; if true, run the body in -/// order with the same first-deny-wins logic. -/// - `Effect::Pdp` — call resolver; on Allow run `on_allow` reactions and -/// continue; on Deny run `on_deny` reactions and return the deny -/// (reactions can override with their own deny, but cannot turn a deny -/// into an allow). -/// - `Effect::Plugin` — invoke; Allow continues, Deny returns. -/// - `Effect::Delegate` — mint downstream credential; writes -/// `delegation.granted.*` keys back into the bag; deny-on-failure unless -/// the step's `on_error` overrides. -/// - `Effect::Taint` — record the label; never halts. -/// - `Effect::FieldOp` — apply a pipe chain to `args.X` / `result.X`; -/// may set `args_modified` / `result_modified`. -/// - `Effect::Sequential` — run children in order, halt on first Deny. -/// - `Effect::Parallel` — run children concurrently, abort on first Deny. -/// - `Effect::Allow` — explicit no-op; continues the phase. -/// - `Effect::Deny` — halt with the supplied reason/code. -/// -/// PDP / plugin errors map to a Deny with the error in the reason, per -/// the design's fail-closed default. `evaluate_steps` -/// is preserved as a deprecated alias that forwards here. -#[allow(clippy::too_many_arguments)] -pub async fn evaluate_effects( - effects: &[Effect], - bag: &mut AttributeBag, - pdp: &Arc, - plugins: &Arc, - delegations: &Arc, - elicitations: &Arc, - phase: crate::step::DispatchPhase, - payload: &mut crate::route::RoutePayload, -) -> StepsEvaluation { - let mut taints: Vec = Vec::new(); - let mut constraints: Vec = Vec::new(); - let mut args_modified = false; - let mut result_modified = false; - let mut pending: Option = None; - for effect in effects { - // Each top-level effect runs against the shared mutable state. - // `Effect::When` / `Effect::Pdp` handle their own internal - // walking via dispatch_effect's recursive call. - let fallback_source = match effect { - Effect::When { source, .. } => source.as_str(), - _ => "", - }; - match Box::pin(dispatch_effect( - effect, - fallback_source, - bag, - pdp, - plugins, - delegations, - elicitations, - phase, - &mut taints, - &mut constraints, - &mut args_modified, - &mut result_modified, - payload, - )) - .await - { - EffectOutcome::Continue => {}, - EffectOutcome::Halt(decision) => { - return StepsEvaluation::deny( - decision, - taints, - constraints, - args_modified, - result_modified, - ); - }, - EffectOutcome::Pending(bundle) => { - // Suspend the phase: stop walking (sequential elicitation), - // carry the bundle out. Decision stays Allow — the host - // gates on `pending` being set, not on a deny. - pending = Some(bundle); - break; - }, - } - } - StepsEvaluation { - decision: Decision::Allow, - taints, - constraints, - args_modified, - result_modified, - pending, - } -} - -/// Outcome of `evaluate_effects`: the phase's decision plus taints emitted -/// by any plugin steps that ran. Taints are accumulated even when the -/// phase ultimately denies — audit needs to see what the plugins -/// reported before the deny landed. Empty `taints` is the common case -/// (most steps are predicates / PDP calls, not label emitters). -/// -/// `args_modified` / `result_modified` are set when an `Effect::FieldOp` -/// inside a `do:` body successfully mutated the route payload — the -/// orchestrator uses them to OR-into the route-level "did anything -/// change" signals so the host knows to re-serialize the body. -#[derive(Debug, Clone)] -pub struct StepsEvaluation { - pub decision: Decision, - pub taints: Vec, - /// Backend candidate constraints emitted by any `restrict` effects - /// that ran. Accumulated even when the phase ultimately denies (same - /// discipline as `taints`). Empty in the common case — most phases - /// have no `restrict`. A higher layer (apl-cpex) folds these into a - /// single `CandidateConstraintExtension` for the host's router. - pub constraints: Vec, - pub args_modified: bool, - pub result_modified: bool, - /// Set when a phase suspended on an unresolved elicitation. `Some` - /// means "do not forward — emit `-32120` with this bundle." `decision` - /// is `Allow` in that case (nothing denied); the host gates forwarding - /// on `pending.is_none()`. See [`crate::step::PendingElicitation`]. - pub pending: Option, -} - -impl StepsEvaluation { - fn deny( - d: Decision, - taints: Vec, - constraints: Vec, - args_modified: bool, - result_modified: bool, - ) -> Self { - Self { - decision: d, - taints, - constraints, - args_modified, - result_modified, - pending: None, - } - } -} - -/// Outcome of dispatching one effect. Internal control-flow signal — -/// never serialized, never exposed in the IR. Sits between the per- -/// effect dispatch (When / Pdp / Plugin / Delegate / Taint / Allow / -/// Deny / FieldOp / Sequential / Parallel) and the caller's "do I keep -/// walking the effects list or halt?" loop. -enum EffectOutcome { - /// Effect completed without producing a Deny — caller moves on to - /// the next effect in the surrounding list. - Continue, - /// Effect produced a Deny decision — caller halts the rest of the - /// surrounding list, the rest of the phase, and the route. - Halt(Decision), - /// Effect dispatched an elicitation that hasn't resolved yet — the - /// phase *suspends*. Like `Halt` it short-circuits the surrounding - /// list, but it is NOT a deny: the carried bundle propagates up to - /// the host, which emits `-32120` (retry) instead of forwarding. The - /// phase decision stays `Allow`; pending being non-empty is what - /// blocks the forward (see [`PendingElicitation`]). - Pending(crate::step::PendingElicitation), -} - -/// Run a single effect against the evaluator's state. Called by both -/// `evaluate_effects` (top-level walk of `policy:` / `post_policy:`) -/// and by recursive arms (Sequential, Parallel, When body, Pdp -/// reactions), so there's exactly one place that knows how each -/// effect kind dispatches. -/// -/// `fallback_source` is the rule-source-position string used as the -/// `rule_source` field on a `Decision::Deny` when the effect itself -/// doesn't carry an explicit code (i.e. `Effect::Deny { code: None }`, -/// or a deny coming back from a plugin / delegator without overriding -/// the default). -#[allow(clippy::too_many_arguments)] -async fn dispatch_effect( - effect: &Effect, - fallback_source: &str, - bag: &mut AttributeBag, - pdp: &Arc, - plugins: &Arc, - delegations: &Arc, - elicitations: &Arc, - phase: crate::step::DispatchPhase, - taints: &mut Vec, - constraints: &mut Vec, - args_modified: &mut bool, - result_modified: &mut bool, - payload: &mut crate::route::RoutePayload, -) -> EffectOutcome { - match effect { - Effect::Allow => EffectOutcome::Continue, - - Effect::Deny { reason, code } => { - // Author-supplied code overrides the auto-generated source - // position. Lets MCP clients dispatch on stable categories - // (`quota.exceeded`) rather than positional codes that - // shift with YAML edits. - let rule_source = code.clone().unwrap_or_else(|| fallback_source.to_string()); - EffectOutcome::Halt(Decision::Deny { - reason: reason.clone(), - rule_source, - }) - }, - - Effect::Plugin { name } => { - match plugins - .invoke(name, bag, PluginInvocation::Step { phase }) - .await - { - Ok(outcome) => { - // Plugins can emit taints regardless of decision — - // collect first, then act on the decision. - taints.extend(outcome.taints); - match outcome.decision { - Decision::Allow => EffectOutcome::Continue, - deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny), - } - }, - Err(e) => EffectOutcome::Halt(Decision::Deny { - reason: Some(format!("plugin `{}` error: {}", name, e)), - rule_source: format!("plugin:{}", name), - }), - } - }, - - Effect::Delegate(delegate_step) => { - match delegations.delegate(delegate_step).await { - Ok(outcome) => match &outcome.decision { - Decision::Allow => { - // Surface granted_* keys into the bag so - // downstream rules in this same step list can - // read them (`require(delegation.granted.permissions - // contains "X")`, etc.). - use crate::attributes::AttributeValue; - use crate::step::delegation_bag_keys as bk; - - bag.set(bk::GRANTED, AttributeValue::Bool(true)); - if !outcome.granted_permissions.is_empty() { - let set: std::collections::HashSet = - outcome.granted_permissions.iter().cloned().collect(); - bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set)); - } - if let Some(aud) = &outcome.granted_audience { - bag.set(bk::GRANTED_AUDIENCE, aud.clone()); - } - if let Some(exp) = &outcome.granted_expires_at { - bag.set(bk::GRANTED_EXPIRES_AT, exp.clone()); - } - EffectOutcome::Continue - }, - Decision::Deny { .. } => { - // Apply the step's on_error policy. Default - // ("deny") halts; "continue" lets the pipeline - // keep going so subsequent rules can branch on - // the absent `delegation.granted` flag. - let on_error = delegate_step - .on_error - .as_deref() - .unwrap_or("deny") - .to_ascii_lowercase(); - if on_error == "continue" { - EffectOutcome::Continue - } else { - EffectOutcome::Halt(outcome.decision) - } - }, - }, - Err(e) => { - // Transport / lookup failure. on_error treats this - // the same way as a plugin-side deny. - let on_error = delegate_step - .on_error - .as_deref() - .unwrap_or("deny") - .to_ascii_lowercase(); - if on_error == "continue" { - EffectOutcome::Continue - } else { - EffectOutcome::Halt(Decision::Deny { - reason: Some(format!( - "delegate `{}` error: {}", - delegate_step.plugin_name, e - )), - rule_source: delegate_step.source.clone(), - }) - } - }, - } - }, - - Effect::Elicit(elicit_step) => dispatch_elicitation(elicit_step, bag, elicitations).await, - - Effect::Taint { label, scopes } => { - // Emit the taint into the phase's accumulator so it flows - // into `RouteDecision.taints`. Apl-cpex's invoker handles - // the session-store persistence side at request end — here - // we only record the event. Scopes come straight from the - // parser (`taint(label, session, message)` syntax). - taints.push(crate::pipeline::TaintEvent { - label: label.clone(), - scopes: scopes.clone(), - }); - EffectOutcome::Continue - }, - - Effect::Restrict { spec } => { - // Resolve any `data.*` field references against this request's - // bag. Allow-list references fail closed by - // resolving to the empty set; an unresolvable *deny*-list - // reference is an integrity failure that denies the request — - // a deny-list we can't resolve must never route unconstrained. - // - // On success we accumulate the literal constraint — same - // discipline as `Taint`: continues, doesn't halt. An all-empty - // result is dropped (the parser rejects a literal empty - // `restrict:`, but a reference resolving to nothing could still - // leave every field unset). Folding / intersection of the - // accumulated constraints happens at the bridge (apl-cpex → - // `CandidateConstraintExtension`). - match spec.resolve(bag) { - Ok(constraint) => { - if !constraint.is_empty() { - constraints.push(constraint); - } - EffectOutcome::Continue - }, - Err(e) => EffectOutcome::Halt(Decision::Deny { - reason: Some(e.to_string()), - rule_source: fallback_source.to_string(), - }), - } - }, - - Effect::FieldOp { path, stages } => { - dispatch_field_op( - path, - stages, - fallback_source, - bag, - plugins, - phase, - taints, - args_modified, - result_modified, - payload, - ) - .await - }, - - Effect::Sequential(effects) => { - // Semantically the same as inlining the list into the - // enclosing scope — walk in order, stop on first Halt. - // The variant exists for explicit grouping and to pair - // with `Parallel` in the IR. - for inner in effects { - match Box::pin(dispatch_effect( - inner, - fallback_source, - bag, - pdp, - plugins, - delegations, - elicitations, - phase, - taints, - constraints, - args_modified, - result_modified, - payload, - )) - .await - { - EffectOutcome::Continue => continue, - // Halt (deny) and Pending (suspend) both propagate up. - other => return other, - } - } - EffectOutcome::Continue - }, - - Effect::Parallel(effects) => { - // `dispatch_parallel` returns an explicit `BoxFuture<'_, _>` - // (Send by construction) so the recursive - // dispatch_effect → dispatch_parallel → dispatch_effect - // chain doesn't trip the compiler's Send-inference cycle. - dispatch_parallel( - effects, - fallback_source, - bag, - pdp, - plugins, - delegations, - elicitations, - phase, - taints, - constraints, - payload, - ) - .await - }, - - Effect::When { - condition, - body, - source, - } => { - // Predicate-gated body — replaces the historical - // `Step::Rule`. Skip silently when the condition is false; - // otherwise walk the body in order and halt on first Deny. - if !eval_expression(condition, bag) { - return EffectOutcome::Continue; - } - for inner in body { - match Box::pin(dispatch_effect( - inner, - source, - bag, - pdp, - plugins, - delegations, - elicitations, - phase, - taints, - constraints, - args_modified, - result_modified, - payload, - )) - .await - { - EffectOutcome::Continue => continue, - // Halt (deny) and Pending (suspend) both propagate up. - other => return other, - } - } - EffectOutcome::Continue - }, - - Effect::Pdp { - call, - on_allow, - on_deny, - } => { - // External PDP call — replaces `Step::Pdp`. Reactions run - // through the same dispatch_effect path (recursively). - match pdp.evaluate(call, bag).await { - Ok(pdp_result) => match pdp_result.decision { - Decision::Allow => { - // Walk on_allow; if it ends without a Halt the - // PDP allow stands and we continue. - for inner in on_allow { - match Box::pin(dispatch_effect( - inner, - fallback_source, - bag, - pdp, - plugins, - delegations, - elicitations, - phase, - taints, - constraints, - args_modified, - result_modified, - payload, - )) - .await - { - EffectOutcome::Continue => continue, - // Halt (deny) and Pending (suspend) propagate. - other => return other, - } - } - EffectOutcome::Continue - }, - deny @ Decision::Deny { .. } => { - // Reactions can override the PDP's deny reason - // (e.g. `on_deny: [deny "..."]`) but cannot - // upgrade the deny to allow — if reactions - // walked clean, the PDP's original deny stands. - for inner in on_deny { - match Box::pin(dispatch_effect( - inner, - fallback_source, - bag, - pdp, - plugins, - delegations, - elicitations, - phase, - taints, - constraints, - args_modified, - result_modified, - payload, - )) - .await - { - EffectOutcome::Continue => {}, - // A reaction can override the deny reason; - // a suspended reaction (Pending) surfaces. - other => return other, - } - } - EffectOutcome::Halt(deny) - }, - }, - Err(e) => EffectOutcome::Halt(Decision::Deny { - reason: Some(format!("PDP error: {}", e)), - rule_source: format!("pdp:{:?}", call.dialect), - }), - } - }, - } -} - -/// Drive one [`Effect::Elicit`] step: dispatch on first arrival, check -/// status on every pass, and on an approved-and-genuine response apply -/// the runtime's `scope`-over-args sufficiency check before allowing the -/// phase to continue. -/// -/// Failure handling follows the step's `on_error` (default `deny`, -/// fail-closed). An explicit human *denial* always halts regardless of -/// `on_error` — `on_error` governs channel/validation *failures*, not a -/// valid "no" from the approver. -/// -/// While the human hasn't answered, the phase *suspends*: the step yields -/// an [`EffectOutcome::Pending`] carrying the bundle the host turns into a -/// JSON-RPC `-32120` (retry) instead of forwarding. The bag also records -/// `elicitation.status = pending` so the host/audit can see the in-flight -/// state. -async fn dispatch_elicitation( - step: &crate::step::ElicitStep, - bag: &mut AttributeBag, - elicitations: &Arc, -) -> EffectOutcome { - use crate::step::{elicitation_bag_keys as bk, ElicitationOutcome, ElicitationStatus}; - - // `on_error` applies to *failures* (channel error, invalid response, - // expiry, still-pending) — not to a genuine denial. - let on_error_continue = step - .on_error - .as_deref() - .unwrap_or("deny") - .eq_ignore_ascii_case("continue"); - let fail = |reason: String| -> EffectOutcome { - if on_error_continue { - EffectOutcome::Continue - } else { - EffectOutcome::Halt(Decision::Deny { - reason: Some(reason), - rule_source: step.source.clone(), - }) - } - }; - - // First arrival vs. retry: the agent echoes `elicitation.id` on - // retry. Absent → first arrival: dispatch and record the pending - // bundle in the bag. - // - // Resolve `from` against the bag (e.g. `claim.manager` → the - // manager's identity). A `from` written as a literal identity (e.g. - // `alice@corp.com`) that isn't a bag key falls through to the literal. - // But a `from` that *looks like* an unresolved attribute reference - // (e.g. `claim.manager` when the claim is absent) must NOT be sent to - // the channel verbatim — that would dispatch an approval to a bogus - // `login_hint`. This is an auth boundary: fail closed instead. The - // attribute vocabulary lives here in the runtime, so the invoker - // receives the resolved identity rather than re-deriving it. - let resolved_from = match bag.get_string(&step.from) { - Some(v) => v.to_string(), - None if looks_like_attribute_ref(&step.from) => { - return fail(format!( - "elicitation `from` attribute `{}` did not resolve to an identity", - step.from - )); - }, - None => step.from.clone(), - }; - let id = match bag.get_string(bk::ID) { - Some(existing) => existing.to_string(), - None => match elicitations.dispatch(step, &resolved_from).await { - Ok(d) => { - bag.set(bk::ID, d.id.clone()); - bag.set(bk::STATUS, "pending".to_string()); - // Optional audit label — not a routing key. - if let Some(channel) = &step.channel { - bag.set(bk::CHANNEL, channel.clone()); - } - if let Some(approver) = &d.approver { - bag.set(bk::APPROVER, approver.clone()); - } - if let Some(intent) = &d.intent_id { - bag.set(bk::INTENT_ID, intent.clone()); - } - if let Some(exp) = &d.expires_at { - bag.set(bk::EXPIRES_AT, exp.clone()); - } - d.id - }, - Err(e) => return fail(format!("elicitation dispatch failed: {e}")), - }, - }; - - // Status check — non-blocking read of the channel's current state. - let status = match elicitations.check(step, &id).await { - Ok(s) => s, - Err(e) => return fail(format!("elicitation check failed: {e}")), - }; - - match status { - ElicitationStatus::Pending => { - // Suspend, don't deny: hand the host a pending bundle so it - // emits `-32120` (retry) instead of forwarding. Built from the - // bag, which dispatch populated — works the same on first - // arrival and on a later still-pending retry. - bag.set(bk::STATUS, "pending".to_string()); - let approver = bag.get_string(bk::APPROVER).map(str::to_string); - let intent_id = bag.get_string(bk::INTENT_ID).map(str::to_string); - let expires_at = bag.get_string(bk::EXPIRES_AT).map(str::to_string); - EffectOutcome::Pending(crate::step::PendingElicitation { - id, - plugin_name: step.plugin_name.clone(), - approver, - intent_id, - channel: step.channel.clone(), - expires_at, - source: step.source.clone(), - }) - }, - ElicitationStatus::Expired => { - bag.set(bk::STATUS, "expired".to_string()); - fail("elicitation expired before a response".to_string()) - }, - ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Denied, - } => { - bag.set(bk::STATUS, "resolved".to_string()); - bag.set(bk::OUTCOME, "denied".to_string()); - // A genuine "no" halts unconditionally — not subject to - // `on_error`. - EffectOutcome::Halt(Decision::Deny { - reason: Some("elicitation denied by approver".to_string()), - rule_source: step.source.clone(), - }) - }, - ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Approved, - } => { - // Genuineness (plugin): signature / intent binding / - // responder identity. - let validation = match elicitations.validate(step, &id).await { - Ok(v) => v, - Err(e) => return fail(format!("elicitation validation failed: {e}")), - }; - if !validation.valid { - let why = validation - .reason - .unwrap_or_else(|| "response failed validation".to_string()); - return fail(format!("elicitation invalid: {why}")); - } - - // Sufficiency (runtime): evaluate `scope` against the live - // args. Kept in APL because the channel (Keycloak CIBA) can't - // bind args — no RFC 9396 RAR. - if let Some(scope_src) = &step.scope { - match crate::parser::parse_predicate(scope_src) { - Ok(expr) => { - if !eval_expression(&expr, bag) { - return fail(format!("elicitation scope not satisfied: `{scope_src}`")); - } - }, - Err(e) => { - return fail(format!( - "elicitation scope `{scope_src}` failed to parse: {e}" - )); - }, - } - } - - // Approved + genuine + sufficient. Record resolved facts for - // downstream rules / audit, then continue. - bag.set(bk::STATUS, "resolved".to_string()); - bag.set(bk::OUTCOME, "approved".to_string()); - if let Some(approver) = validation.approver { - bag.set(bk::APPROVER, approver); - } - if let Some(intent) = validation.intent_id { - bag.set(bk::INTENT_ID, intent); - } - EffectOutcome::Continue - }, - } -} - -/// Run a list of effects concurrently. Each branch gets its own -/// cloned bag and payload — mutations inside a branch don't -/// propagate back to the shared outer state. Taints from every -/// branch are merged into the outer `taints` vec (taints are -/// append-only event logs, safe to concatenate). First Halt by -/// branch index wins; the remaining branches are aborted via -/// `cpex_orchestration::run_branches`'s `short_circuit_on_deny`. -/// -/// Config-load already rejected `FieldOp` / `Delegate` here via -/// [`Effect::validate_parallel_purity`], so at runtime we trust the -/// IR not to contain mutation effects. -/// -/// # Concurrency model -/// -/// Built on [`cpex_orchestration::run_branches`] — the same JoinSet -/// + abort-on-deny primitive `cpex-core`'s executor uses for its -/// concurrent phase. Each branch is `tokio::spawn`ed onto the -/// runtime, so branches get true OS-thread parallelism (vs. the v1 -/// implementation's `join_all`, which only interleaved on one -/// task). To meet the `'static + Send` bounds for spawning, the -/// invoker references are `&Arc` — we `Arc::clone` an -/// owned reference into each branch closure. -/// -/// Note: no per-branch timeout. The DSL doesn't expose one, and -/// plugin-level timeouts upstream of this call (in cpex-core's -/// executor) bound individual plugin invocations. If a route ever -/// needs a per-branch budget the orchestration crate already -/// supports `BranchConfig::timeout_per_branch` — wire it through a -/// `Effect::Parallel` extension if/when needed. -// Returns an explicit `BoxFuture` rather than `impl Future` so the -// caller (`dispatch_effect`'s `Effect::Parallel` arm, which is itself -// `async fn`) can break the Send-inference cycle this would otherwise -// introduce: dispatch_effect's opaque return type would depend on -// dispatch_parallel's, and dispatch_parallel spawns futures that -// recursively re-enter dispatch_effect. A concrete `BoxFuture` is -// `Pin>` — already Send by construction, -// no inference required. -fn dispatch_parallel<'a>( - effects: &'a [Effect], - fallback_source: &'a str, - bag: &'a AttributeBag, - pdp: &'a Arc, - plugins: &'a Arc, - delegations: &'a Arc, - elicitations: &'a Arc, - phase: crate::step::DispatchPhase, - taints: &'a mut Vec, - constraints: &'a mut Vec, - payload: &'a crate::route::RoutePayload, -) -> futures::future::BoxFuture<'a, EffectOutcome> { - Box::pin(async move { - use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; - - if effects.is_empty() { - return EffectOutcome::Continue; - } - - // Build one spawn-ready branch future per effect. Each branch - // owns: - // * a cloned bag and payload — branch mutations stay local; - // * cloned Arcs to the invokers — `'static + Send`, ready for - // `tokio::spawn`; - // * an owned copy of the effect to evaluate (clone is cheap - // for the variants `Parallel` can hold: Allow, Deny, Plugin, - // Taint, Sequential, Parallel, When, Pdp). - type BranchResult = ( - EffectOutcome, - Vec, - Vec, - ); - let mut branches: Vec> = Vec::with_capacity(effects.len()); - for effect in effects.iter() { - let effect = effect.clone(); - let fallback = fallback_source.to_string(); - let mut branch_bag = bag.clone(); - let mut branch_payload = payload.clone(); - let pdp = Arc::clone(pdp); - let plugins = Arc::clone(plugins); - let delegations = Arc::clone(delegations); - let elicitations = Arc::clone(elicitations); - branches.push(Box::pin(async move { - let mut branch_taints: Vec = Vec::new(); - let mut branch_constraints: Vec = - Vec::new(); - let mut branch_args_modified = false; - let mut branch_result_modified = false; - let outcome = Box::pin(dispatch_effect( - &effect, - &fallback, - &mut branch_bag, - &pdp, - &plugins, - &delegations, - &elicitations, - phase, - &mut branch_taints, - &mut branch_constraints, - &mut branch_args_modified, - &mut branch_result_modified, - &mut branch_payload, - )) - .await; - (outcome, branch_taints, branch_constraints) - })); - } - - // `is_deny` short-circuits the moment any branch returns - // `EffectOutcome::Halt(_)`. The remaining branches get - // `BranchOutcome::Aborted` and we drop their (already-cancelled) - // futures. Taints from already-completed branches still land. - let cfg = BranchConfig { - timeout_per_branch: None, - short_circuit_on_deny: true, - }; - let outcomes = run_branches(branches, cfg, |v: &BranchResult| { - matches!(v.0, EffectOutcome::Halt(_)) - }) - .await; - - // Aggregate in input order: append every branch's taints; pick - // the first Halt (by branch index, not wall-clock order) as the - // overall result. Aborted / panicked branches contribute no - // taints — they didn't run to completion. A panicked branch is - // *not* converted into a Halt; we log via `tracing::warn!` and - // continue. (A misbehaving plugin shouldn't take down the - // parallel block any more than it would the host process.) - let mut first_halt: Option = None; - for (idx, outcome) in outcomes.into_iter().enumerate() { - match outcome { - BranchOutcome::Completed((effect_outcome, branch_taints, branch_constraints)) => { - // Branch state merges back append-only, same as taints: - // each branch's `restrict`-emitted constraints land in - // the outer accumulator (they intersect at fold time, - // so order-independent — safe to concatenate). - taints.extend(branch_taints); - constraints.extend(branch_constraints); - if first_halt.is_none() { - if let EffectOutcome::Halt(d) = effect_outcome { - first_halt = Some(d); - } - } - }, - BranchOutcome::Aborted => { - // Short-circuit cancelled this branch — intentional, - // no diagnostic needed. - }, - BranchOutcome::TimedOut => { - // Unreachable today (no per-branch timeout - // configured). Treat as a no-op if it ever fires - // post-config-extension. - }, - BranchOutcome::Panicked(msg) => { - // A panicking branch is a misbehaving plugin/effect; - // dropping its output (no Halt, no taints) keeps the - // parallel block's other branches intact rather than - // taking the whole block down. apl-core has no - // tracing dep — host integrations that care can - // surface the panic via cpex-core's plugin error - // path. `idx`/`msg` are eaten here. - let _ = (idx, msg); - }, - } - } - - match first_halt { - Some(d) => EffectOutcome::Halt(d), - None => EffectOutcome::Continue, - } - }) -} - -/// Apply a `FieldOp` effect — resolve the path in args/result, run -/// the pipeline stages, write the outcome back into the payload. -/// -/// Out-of-phase ops are silent no-ops: a Pre-phase rule with -/// `result.X | redact` skips because the result hasn't been produced -/// yet; a Post-phase rule with `args.X | redact` skips because the -/// args were already sent on the wire. This is intentional so the -/// same `when:`/`do:` rule body can be reused across phases without -/// the author needing to branch on phase. -/// -/// Missing fields skip silently too (same as the args:/result: phase -/// pipelines) — a pipeline can't transform what isn't there. If the -/// author needs presence semantics, that's a `require(exists(args.X))` -/// upstream of the `do:` body. -#[allow(clippy::too_many_arguments)] -async fn dispatch_field_op( - path: &str, - stages: &[crate::pipeline::Stage], - fallback_source: &str, - bag: &mut AttributeBag, - plugins: &Arc, - phase: crate::step::DispatchPhase, - taints: &mut Vec, - args_modified: &mut bool, - result_modified: &mut bool, - payload: &mut crate::route::RoutePayload, -) -> EffectOutcome { - use crate::route::{get_dotted, remove_dotted, set_dotted}; - use crate::step::DispatchPhase; - - // Pick the right side of the payload based on the path prefix. - // Out-of-phase ops drop silently (see the doc comment). - enum Side { - Args, - Result, - } - let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") { - if !matches!(phase, DispatchPhase::Pre) { - return EffectOutcome::Continue; - } - (&mut payload.args, rest, Side::Args) - } else if let Some(rest) = path.strip_prefix("result.") { - if !matches!(phase, DispatchPhase::Post) { - return EffectOutcome::Continue; - } - let Some(result) = payload.result.as_mut() else { - return EffectOutcome::Continue; - }; - (result, rest, Side::Result) - } else { - return EffectOutcome::Halt(Decision::Deny { - reason: Some(format!( - "FieldOp path `{}` must start with `args.` or `result.`", - path - )), - rule_source: fallback_source.to_string(), - }); - }; - - let Some(current) = get_dotted(root, subpath).cloned() else { - return EffectOutcome::Continue; // missing field → silent no-op - }; - - let pipeline = crate::pipeline::Pipeline { - stages: stages.to_vec(), - }; - // `subpath`, not `path`: the field name a pipeline reports to a - // plugin is relative to the args / result root, matching what the - // `args:` / `result:` section pipelines pass. The prefixed `path` - // stays in use for deny messages, where the reader wants the side - // spelled out. - let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, subpath, phase).await; - taints.extend(eval.taints); - let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side { - Side::Args => *args = true, - Side::Result => *result = true, - }; - match eval.outcome { - FieldOutcome::Pass => EffectOutcome::Continue, - FieldOutcome::Replace(new_val) => { - if set_dotted(root, subpath, new_val) { - mark_modified(side, args_modified, result_modified); - } - EffectOutcome::Continue - }, - FieldOutcome::Omit => { - if remove_dotted(root, subpath) { - mark_modified(side, args_modified, result_modified); - } - EffectOutcome::Continue - }, - FieldOutcome::Deny { - reason, - stage_index: _, - } => EffectOutcome::Halt(Decision::Deny { - reason: Some(reason), - rule_source: fallback_source.to_string(), - }), - } -} - -/// Result of running a pipeline against one field's value. -/// -/// `Pass`: every stage succeeded; the original value should be kept. -/// `Replace`: a transform produced a new value (also covers conditional -/// `redact` firing). -/// `Omit`: an `omit` stage fired; the field should be dropped from output. -/// `Deny`: a validator failed; pipeline halted; the route should deny. -#[derive(Debug, Clone, PartialEq)] -pub enum FieldOutcome { - Pass, - Replace(serde_json::Value), - Omit, - Deny { reason: String, stage_index: usize }, -} - -/// Full result of a pipeline run: value-level outcome plus accumulated -/// taint side effects. -/// -/// `taint(...)` stages, plugin invocations, and `scan(...)` stages can all -/// emit taints; the evaluator collects them here and hands them to the host -/// (apl-cpex) for SessionStore writes. Taints accumulate even on `Replace` -/// and `Omit` outcomes; they do not accumulate past a `Deny` (the pipeline -/// halts at the failing stage). -#[derive(Debug, Clone, PartialEq)] -pub struct PipelineEvaluation { - pub outcome: FieldOutcome, - pub taints: Vec, -} - -/// Walk a pipeline against `value` and the bag, applying stages left-to-right. -/// -/// Async because pipe-chain `plugin(name)` stages dispatch through -/// `PluginInvoker`, which is async. -/// -/// `field_name` is the field this pipeline is attached to (from the wrapping -/// `FieldRule`). It's threaded into `PluginInvocation::Field` when a -/// `Stage::Plugin` fires so the invoker knows which field is in focus. -/// Pass `""` for standalone pipeline runs that aren't part of a field rule. -/// -/// `Stage::Validate { name }` has no named-validator registry backing it; -/// it always denies with a message pointing operators at `regex(...)` or -/// `plugin(...)` instead. -pub async fn evaluate_pipeline( - pipeline: &Pipeline, - value: &serde_json::Value, - bag: &AttributeBag, - plugins: &Arc, - field_name: &str, - phase: crate::step::DispatchPhase, -) -> PipelineEvaluation { - let mut current = value.clone(); - let mut replaced = false; - let mut taints: Vec = Vec::new(); - - for (idx, stage) in pipeline.stages.iter().enumerate() { - match stage { - Stage::Type(tc) => { - if !type_check(tc, ¤t) { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("expected {:?}, got {}", tc, value_kind(¤t)), - stage_index: idx, - }, - taints, - }; - } - }, - Stage::Length { min, max } => { - let Some(s) = current.as_str() else { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!( - "len(...) requires string value, got {}", - value_kind(¤t) - ), - stage_index: idx, - }, - taints, - }; - }; - let len = s.chars().count(); - if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("length {} outside [{:?}, {:?}]", len, min, max), - stage_index: idx, - }, - taints, - }; - } - }, - Stage::Range { min, max } => { - let Some(n) = current.as_i64() else { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!( - "range requires integer value, got {}", - value_kind(¤t) - ), - stage_index: idx, - }, - taints, - }; - }; - if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("value {} outside [{:?}, {:?}]", n, min, max), - stage_index: idx, - }, - taints, - }; - } - }, - Stage::Enum { values } => { - let Some(s) = current.as_str() else { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!( - "enum(...) requires string value, got {}", - value_kind(¤t) - ), - stage_index: idx, - }, - taints, - }; - }; - if !values.iter().any(|v| v == s) { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("value `{}` not in enum {:?}", s, values), - stage_index: idx, - }, - taints, - }; - } - }, - Stage::Regex { pattern } => { - // Compile-at-eval for now. A future step can swap to a - // route-level pre-compile cache keyed by pattern. - let re = match regex::Regex::new(pattern) { - Ok(r) => r, - Err(e) => { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("invalid regex `{}`: {}", pattern, e), - stage_index: idx, - }, - taints, - }; - }, - }; - let Some(s) = current.as_str() else { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!( - "regex requires string value, got {}", - value_kind(¤t) - ), - stage_index: idx, - }, - taints, - }; - }; - if !re.is_match(s) { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("value did not match regex `{}`", pattern), - stage_index: idx, - }, - taints, - }; - } - }, - Stage::Validate { name } => { - // Named-validator dispatch is not implemented in this - // build. The parser rejects `validate(...)` at compile - // time (parser.rs); this branch covers IR built - // programmatically bypassing the parser. Same shape - // as the parser's diagnostic — operators reach for - // `regex(...)` or `plugin(...)` instead. - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!( - "`validate({})` is not implemented; use `regex(...)` \ - or `plugin({})` instead", - name, name, - ), - stage_index: idx, - }, - taints, - }; - }, - - Stage::Mask { keep_last } => { - let Some(s) = current.as_str() else { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!( - "mask(...) requires string value, got {}", - value_kind(¤t) - ), - stage_index: idx, - }, - taints, - }; - }; - let chars: Vec = s.chars().collect(); - let keep = (*keep_last).min(chars.len()); - let mask_count = chars.len() - keep; - let masked: String = std::iter::repeat('*') - .take(mask_count) - .chain(chars.into_iter().skip(mask_count)) - .collect(); - current = serde_json::Value::String(masked); - replaced = true; - }, - Stage::Redact { condition } => { - let should_redact = match condition { - None => true, - Some(expr) => eval_expression(expr, bag), - }; - if should_redact { - current = serde_json::Value::String("[REDACTED]".into()); - replaced = true; - } - }, - Stage::Omit => { - return PipelineEvaluation { - outcome: FieldOutcome::Omit, - taints, - }; - }, - Stage::Hash => { - // Simple deterministic digest — DefaultHasher is fine for - // de-identification (not for cryptographic use). - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - value_for_hash(¤t).hash(&mut h); - current = serde_json::Value::String(format!("hash:{:016x}", h.finish())); - replaced = true; - }, - - Stage::Taint { label, scopes } => { - taints.push(TaintEvent { - label: label.clone(), - scopes: scopes.clone(), - }); - }, - Stage::Plugin { name } => { - // Known limitation: `current` carries the edits earlier - // stages made, but the invoker's payload does not — a - // `mask` before this stage is visible here and invisible - // to the plugin, which reads the field from the payload. - // Pushing interim pipeline state into the payload would - // change what every later plugin in the request sees, so - // it's left alone deliberately. - let invocation = PluginInvocation::Field { - name: field_name, - value: ¤t, - phase, - }; - match plugins.invoke(name, bag, invocation).await { - Ok(outcome) => { - // Plugins can emit taints regardless of decision. - taints.extend(outcome.taints); - match outcome.decision { - Decision::Allow => { - if let Some(new_value) = outcome.modified_value { - current = new_value; - replaced = true; - } - }, - Decision::Deny { - reason, - rule_source: _, - } => { - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: reason - .unwrap_or_else(|| format!("plugin `{}` denied", name)), - stage_index: idx, - }, - taints, - }; - }, - } - }, - Err(e) => { - // Fail-closed: plugin dispatch failure halts the pipeline. - return PipelineEvaluation { - outcome: FieldOutcome::Deny { - reason: format!("plugin `{}` error: {}", name, e), - stage_index: idx, - }, - taints, - }; - }, - } - }, - Stage::Scan { kind } => { - // Spec mapping: scan stages are taint - // emitters. The actual PII detection / injection signal - // lives in plugin(...) variants of the same scanners; this - // stage just records the label so downstream policies can - // gate on it. `pii.redact` additionally rewrites the value. - let (label, redact): (&str, bool) = match kind { - ScanKind::PiiDetect => ("PII", false), - ScanKind::PiiRedact => ("PII", true), - ScanKind::InjectionScan => ("injection", false), - }; - taints.push(TaintEvent { - label: label.to_string(), - scopes: vec![TaintScope::Session], - }); - if redact { - current = serde_json::Value::String("[REDACTED]".into()); - replaced = true; - } - }, - } - } - - let outcome = if replaced { - FieldOutcome::Replace(current) - } else { - FieldOutcome::Pass - }; - PipelineEvaluation { outcome, taints } -} - -fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool { - match tc { - TypeCheck::Str => v.is_string(), - TypeCheck::Int => v.is_i64(), - TypeCheck::Bool => v.is_boolean(), - TypeCheck::Float => v.is_f64() || v.is_i64(), - TypeCheck::Email => v - .as_str() - .map_or(false, |s| s.contains('@') && s.contains('.')), - TypeCheck::Url => v.as_str().map_or(false, |s| { - s.starts_with("http://") || s.starts_with("https://") - }), - TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape), - } -} - -fn is_uuid_shape(s: &str) -> bool { - // 8-4-4-4-12 hex with `-` separators. - let bytes = s.as_bytes(); - if bytes.len() != 36 { - return false; - } - for (i, &b) in bytes.iter().enumerate() { - match i { - 8 | 13 | 18 | 23 => { - if b != b'-' { - return false; - } - }, - _ => { - if !b.is_ascii_hexdigit() { - return false; - } - }, - } - } - true -} - -fn value_kind(v: &serde_json::Value) -> &'static str { - match v { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "bool", - serde_json::Value::Number(n) if n.is_i64() => "int", - serde_json::Value::Number(_) => "float", - serde_json::Value::String(_) => "string", - serde_json::Value::Array(_) => "array", - serde_json::Value::Object(_) => "object", - } -} - -/// Stable byte representation of a value for hashing — serde_json's -/// `to_string` is canonical enough for our use. -fn value_for_hash(v: &serde_json::Value) -> String { - serde_json::to_string(v).unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::rules::{Condition, Expression, Rule}; - use crate::step::{DelegationInvoker, NoopDelegationInvoker}; - use std::collections::HashSet; - use std::sync::Arc; - - fn rule(condition: Expression, effect: Effect, source: &str) -> Rule { - Rule::single(condition, effect, source) - } - - // Wrap stateless test invokers in `Arc` once per call. The - // public evaluator API takes `&Arc` so internal - // dispatch (notably `Effect::Parallel`) can `Arc::clone` an owned, - // 'static reference into each spawned branch. - fn null_pipe_plugins() -> Arc { - Arc::new(NullPipelinePlugins) - } - fn null_plugins() -> Arc { - Arc::new(NullPlugins) - } - fn noop_delegations() -> Arc { - Arc::new(NoopDelegationInvoker) - } - fn noop_elicitations() -> Arc { - Arc::new(crate::step::NoopElicitationInvoker) - } - fn auto_elicitations() -> Arc { - Arc::new(crate::step::AutoApprovingElicitor) - } - - fn deny(reason: &str) -> Effect { - Effect::Deny { - reason: Some(reason.into()), - code: None, - } - } - - /// Build an `Effect::Elicit` with the given scope / on_error for the - /// elicitation-arm tests. Channel/kind are fixed — the arm doesn't - /// branch on them (that's the plugin's job, which the fakes stub). - fn elicit_effect(scope: Option<&str>, on_error: Option<&str>) -> Effect { - Effect::Elicit(crate::step::ElicitStep { - kind: crate::step::ElicitKind::Approval, - plugin_name: "manager-approver".into(), - channel: Some("ciba".into()), - from: "user.manager".into(), - purpose: Some("approve payroll adjustment".into()), - scope: scope.map(|s| s.to_string()), - timeout: None, - config_override: None, - on_error: on_error.map(|s| s.to_string()), - source: "route.test.policy[0]".into(), - }) - } - - fn cond(c: Condition) -> Expression { - Expression::Condition(c) - } - - // ----- data.* path interpolation ----- - - /// Parse a predicate and evaluate it against `bag`. - fn eval_pred(src: &str, bag: &AttributeBag) -> bool { - let expr = crate::parser::parse_predicate(src).expect("parse predicate"); - eval_expression(&expr, bag) - } - - fn eu_tenant_bag() -> AttributeBag { - let mut bag = AttributeBag::new(); - bag.set("subject.tenant", "acme-eu"); - bag.set("data.tenants.acme-eu.data_region", "eu"); - bag.set("data.tenants.acme-us.data_region", "us"); - bag.set("data.org.default_region", "us"); - bag - } - - #[test] - fn interpolation_resolves_request_value_into_path() { - let bag = eu_tenant_bag(); - // subject.tenant = acme-eu → data.tenants.acme-eu.data_region = eu. - assert!(eval_pred( - "data.tenants[subject.tenant].data_region == 'eu'", - &bag - )); - assert!(!eval_pred( - "data.tenants[subject.tenant].data_region == 'us'", - &bag - )); - } - - #[test] - fn interpolation_picks_up_different_request_value() { - let mut bag = eu_tenant_bag(); - bag.set("subject.tenant", "acme-us"); // now resolves to the US row - assert!(eval_pred( - "data.tenants[subject.tenant].data_region == 'us'", - &bag - )); - } - - #[test] - fn missing_inner_key_makes_comparison_false() { - let mut bag = eu_tenant_bag(); - // Drop the request value the bracket indexes on. - bag = { - let mut b = AttributeBag::new(); - for (k, v) in bag.iter() { - if k != "subject.tenant" { - b.set(k, v.clone()); - } - } - b - }; - assert!(!eval_pred( - "data.tenants[subject.tenant].data_region == 'eu'", - &bag - )); - } - - #[test] - fn missing_inner_key_keeps_require_fail_closed() { - // `require(X)` desugars to a *rule* — condition `IsFalse(X)` + deny — - // NOT a bare `IsTrue` predicate. An unresolvable interpolated path is - // absent → falsy → `IsFalse(X)` is true → the rule fires → deny. This - // is the invariant `require` exists for, so drive the real require - // path (`parse_rule` + `evaluate_rules`), not `parse_predicate`. - let rule = - crate::parser::parse_rule("require(data.tenants[subject.tenant].data_region)", "test") - .unwrap(); - let bag = AttributeBag::new(); // no subject.tenant, no data.* - assert!( - matches!(evaluate_rules(&[rule], &bag), Decision::Deny { .. }), - "require over an unresolvable path must deny (fail closed)", - ); - } - - #[test] - fn require_over_resolvable_truthy_path_allows() { - // Companion to the fail-closed case: when the interpolated path - // resolves to a truthy boolean, `IsFalse(X)` is false → the require - // rule does NOT fire → allow. Proves the deny above comes from - // unresolvability, not from `require` always denying. - let rule = - crate::parser::parse_rule("require(data.tenants[subject.tenant].allowed)", "test") - .unwrap(); - let mut bag = AttributeBag::new(); - bag.set("subject.tenant", "acme"); - bag.set("data.tenants.acme.allowed", true); - assert_eq!(evaluate_rules(&[rule], &bag), Decision::Allow); - } - - #[test] - fn interpolation_works_with_contains_on_a_set() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "support-bot"); - bag.set( - "data.agents.support-bot.allowed_models", - std::collections::HashSet::from(["vllm/*".to_string(), "anthropic/*".to_string()]), - ); - assert!(eval_pred( - "data.agents[subject.id].allowed_models contains 'vllm/*'", - &bag - )); - assert!(!eval_pred( - "data.agents[subject.id].allowed_models contains 'openai/*'", - &bag - )); - } - - #[test] - fn numeric_request_value_coerces_into_path() { - let mut bag = AttributeBag::new(); - bag.set("subject.tier", 2i64); - bag.set("data.limits.2.max_cost", "cheap"); - assert!(eval_pred( - "data.limits[subject.tier].max_cost == 'cheap'", - &bag - )); - } - - #[test] - fn non_interpolated_keys_still_work() { - let bag = eu_tenant_bag(); - assert!(eval_pred("data.org.default_region == 'us'", &bag)); - assert!(eval_pred("subject.tenant == 'acme-eu'", &bag)); - } - - // ----- Decision-level semantics ----- - - #[test] - fn empty_rules_allow() { - let mut bag = AttributeBag::new(); - assert_eq!(evaluate_rules(&[], &bag), Decision::Allow); - } - - #[test] - fn first_deny_halts() { - let mut bag = AttributeBag::new(); - bag.set("a", true); - bag.set("b", true); - - let rules = vec![ - rule( - cond(Condition::IsTrue { key: "a".into() }), - deny("first"), - "r0", - ), - rule( - cond(Condition::IsTrue { key: "b".into() }), - deny("second"), - "r1", - ), - ]; - - match evaluate_rules(&rules, &bag) { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!(reason.as_deref(), Some("first")); - assert_eq!(rule_source, "r0"); - }, - d => panic!("expected Deny, got {:?}", d), - } - } - - #[test] - fn allow_does_not_short_circuit() { - // Explicit allow continues evaluation. A later deny still fires. - let mut bag = AttributeBag::new(); - bag.set("ok", true); - bag.set("bad", true); - - let rules = vec![ - rule( - cond(Condition::IsTrue { key: "ok".into() }), - Effect::Allow, - "r0_allow", - ), - rule( - cond(Condition::IsTrue { key: "bad".into() }), - deny("later"), - "r1_deny", - ), - ]; - - match evaluate_rules(&rules, &bag) { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"), - d => panic!("allow short-circuited; expected later deny, got {:?}", d), - } - } - - #[test] - fn unmatched_rules_dont_fire() { - let mut bag = AttributeBag::new(); // "denied" missing → false - let rules = vec![rule( - cond(Condition::IsTrue { - key: "denied".into(), - }), - deny("shouldn't fire"), - "r0", - )]; - assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); - } - - #[test] - fn missing_key_is_false() { - let mut bag = AttributeBag::new(); - assert!(!eval_condition( - &Condition::IsTrue { - key: "missing".into() - }, - &bag - )); - assert!(eval_condition( - &Condition::IsFalse { - key: "missing".into() - }, - &bag - )); - // Comparison on missing → false. - assert!(!eval_condition( - &Condition::Comparison { - key: "missing".into(), - op: CompareOp::Eq, - value: 1_i64.into(), - }, - &bag, - )); - } - - #[test] - fn and_or_not_combinators() { - let mut bag = AttributeBag::new(); - bag.set("a", true); - bag.set("b", false); - - let a = cond(Condition::IsTrue { key: "a".into() }); - let b = cond(Condition::IsTrue { key: "b".into() }); - - assert!(eval_expression( - &Expression::And(vec![a.clone(), a.clone()]), - &bag - )); - assert!(!eval_expression( - &Expression::And(vec![a.clone(), b.clone()]), - &bag - )); - assert!(eval_expression( - &Expression::Or(vec![a.clone(), b.clone()]), - &bag - )); - assert!(!eval_expression( - &Expression::Or(vec![b.clone(), b.clone()]), - &bag - )); - assert!(eval_expression(&Expression::Not(Box::new(b)), &bag)); - } - - #[test] - fn int_comparisons() { - let mut bag = AttributeBag::new(); - bag.set("delegation.depth", 3_i64); - - let cmp = |op| Condition::Comparison { - key: "delegation.depth".into(), - op, - value: 2_i64.into(), - }; - assert!(eval_condition(&cmp(CompareOp::Gt), &bag)); - assert!(eval_condition(&cmp(CompareOp::GtEq), &bag)); - assert!(!eval_condition(&cmp(CompareOp::Lt), &bag)); - assert!(!eval_condition(&cmp(CompareOp::Eq), &bag)); - assert!(eval_condition(&cmp(CompareOp::NotEq), &bag)); - } - - #[test] - fn int_to_float_promotion_in_comparison() { - let mut bag = AttributeBag::new(); - bag.set("delegation.depth", 2_i64); - // `delegation.depth > 2.5` — int promotes to float for the compare. - assert!(!eval_condition( - &Condition::Comparison { - key: "delegation.depth".into(), - op: CompareOp::Gt, - value: 2.5_f64.into(), - }, - &bag, - )); - assert!(eval_condition( - &Condition::Comparison { - key: "delegation.depth".into(), - op: CompareOp::Lt, - value: 2.5_f64.into(), - }, - &bag, - )); - } - - #[test] - fn numeric_string_args_coerce_for_order_comparison() { - // LLMs routinely send numeric tool-args as strings, e.g. - // `args.amount = "25000"`. An order comparison must still fire so a - // gate like `when: args.amount > 10000` (and the elicitation - // `scope: args.amount <= 25000`) work. Regression for the demo bug - // where a string amount slipped past the approval threshold. - let mut bag = AttributeBag::new(); - bag.set("args.amount", "25000"); - let cmp = |op, v: i64| Condition::Comparison { - key: "args.amount".into(), - op, - value: v.into(), - }; - assert!( - eval_condition(&cmp(CompareOp::Gt, 10000), &bag), - "\"25000\" > 10000" - ); - assert!( - eval_condition(&cmp(CompareOp::LtEq, 25000), &bag), - "\"25000\" <= 25000" - ); - assert!( - !eval_condition(&cmp(CompareOp::Gt, 25000), &bag), - "\"25000\" > 25000 is false" - ); - - // A genuinely non-numeric string still doesn't order-compare. - bag.set("args.amount", "lots"); - assert!( - !eval_condition(&cmp(CompareOp::Gt, 10000), &bag), - "\"lots\" > 10000 is false" - ); - } - - #[test] - fn string_equality_no_ordering() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - - assert!(eval_condition( - &Condition::Comparison { - key: "subject.id".into(), - op: CompareOp::Eq, - value: "alice".into(), - }, - &bag, - )); - // Order operators on strings → false. - assert!(!eval_condition( - &Condition::Comparison { - key: "subject.id".into(), - op: CompareOp::Gt, - value: "alice".into(), - }, - &bag, - )); - } - - #[test] - fn contains_set_membership() { - let mut bag = AttributeBag::new(); - bag.set( - "session.labels", - HashSet::from(["PII".to_string(), "financial".to_string()]), - ); - - assert!(eval_condition( - &Condition::Comparison { - key: "session.labels".into(), - op: CompareOp::Contains, - value: "PII".into(), - }, - &bag, - )); - assert!(!eval_condition( - &Condition::Comparison { - key: "session.labels".into(), - op: CompareOp::Contains, - value: "PHI".into(), - }, - &bag, - )); - // Contains on a non-set attribute → false. - bag.set("subject.id", "alice"); - assert!(!eval_condition( - &Condition::Comparison { - key: "subject.id".into(), - op: CompareOp::Contains, - value: "alice".into(), - }, - &bag, - )); - } - - #[test] - fn hr_compensation_scenario() { - // From the HR demo: alice (hr role + view_ssn perm) requests compensation - // with delegation.depth = 1. Rules: - // 1. require(authenticated) - // 2. require(role.hr | role.finance) - // 3. delegation.depth > 2 & include_ssn: deny - // 4. !perm.view_ssn & include_ssn: deny - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("role.hr", true); - bag.set("perm.view_ssn", true); - bag.set("delegation.depth", 1_i64); - bag.set("include_ssn", true); - - let rules = vec![ - // require(authenticated) → deny if !authenticated - rule( - Expression::Not(Box::new(cond(Condition::IsTrue { - key: "authenticated".into(), - }))), - deny("not authenticated"), - "r0", - ), - // require(role.hr | role.finance) → deny if neither - // Desugars to: when !(role.hr | role.finance) do deny - // = when (role.hr is false) AND (role.finance is false), deny - rule( - Expression::And(vec![ - cond(Condition::IsFalse { - key: "role.hr".into(), - }), - cond(Condition::IsFalse { - key: "role.finance".into(), - }), - ]), - deny("not in hr/finance"), - "r1", - ), - // delegation.depth > 2 & include_ssn: deny - rule( - Expression::And(vec![ - cond(Condition::Comparison { - key: "delegation.depth".into(), - op: CompareOp::Gt, - value: 2_i64.into(), - }), - cond(Condition::IsTrue { - key: "include_ssn".into(), - }), - ]), - deny("delegation too deep for SSN"), - "r2", - ), - ]; - - assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); - - // Now make Alice undelegated-but-deep — should still allow at depth=1. - // Change to depth=3 and the SSN rule fires. - bag.set("delegation.depth", 3_i64); - match evaluate_rules(&rules, &bag) { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"), - d => panic!("expected r2 deny, got {:?}", d), - } - } - - use crate::pipeline::{Stage, TypeCheck}; - use serde_json::json; - - fn make_pipeline(stages: Vec) -> crate::pipeline::Pipeline { - crate::pipeline::Pipeline { stages } - } - - // Helper: a plugin invoker that's never expected to fire (pipelines - // without `plugin(...)` stages). Panics if called. Defined alongside - // the other null fixtures further down in this module. - - async fn run_pipeline( - p: &crate::pipeline::Pipeline, - v: &serde_json::Value, - bag: &AttributeBag, - ) -> FieldOutcome { - evaluate_pipeline( - p, - v, - bag, - &null_pipe_plugins(), - "test_field", - crate::step::DispatchPhase::Pre, - ) - .await - .outcome - } - - /// Pipeline-test null invoker — distinct from the step-test `NullPlugins` - /// so each test can panic with a clearer "wrong fixture" message if it - /// ever does dispatch a plugin call by accident. - struct NullPipelinePlugins; - #[async_trait] - impl PluginInvoker for NullPipelinePlugins { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - panic!( - "NullPipelinePlugins should not dispatch; got plugin({})", - name - ); - } - } - - #[tokio::test] - async fn pipeline_empty_is_pass() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![]); - assert_eq!( - run_pipeline(&p, &json!("anything"), &bag).await, - FieldOutcome::Pass - ); - } - - #[tokio::test] - async fn pipeline_type_check_passes_and_denies() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]); - assert_eq!( - run_pipeline(&p, &json!("hello"), &bag).await, - FieldOutcome::Pass - ); - match run_pipeline(&p, &json!(42), &bag).await { - FieldOutcome::Deny { - reason, - stage_index, - } => { - assert!(reason.contains("expected Str")); - assert_eq!(stage_index, 0); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_mask_preserves_last_n() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]); - match run_pipeline(&p, &json!("123-45-6789"), &bag).await { - FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")), - other => panic!("expected Replace, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_mask_handles_short_strings() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]); - // keep_last >= length → no mask chars; full string preserved. - match run_pipeline(&p, &json!("ab"), &bag).await { - FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")), - other => panic!("expected Replace, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_unconditional_redact() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Redact { condition: None }]); - match run_pipeline(&p, &json!("secret"), &bag).await { - FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")), - other => panic!("expected Replace, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_conditional_redact_fires_when_condition_true() { - // redact(!perm.view_ssn): condition is `!perm.view_ssn`. Missing key - // → IsTrue is false → `!IsTrue` is true → redact fires. - let mut bag = AttributeBag::new(); - let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue { - key: "perm.view_ssn".into(), - }))); - let p = make_pipeline(vec![Stage::Redact { - condition: Some(cond), - }]); - match run_pipeline(&p, &json!("123-45-6789"), &bag).await { - FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")), - other => panic!("expected Replace (redact fired), got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_conditional_redact_skips_when_condition_false() { - let mut bag = AttributeBag::new(); - bag.set("perm.view_ssn", true); - let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue { - key: "perm.view_ssn".into(), - }))); - let p = make_pipeline(vec![Stage::Redact { - condition: Some(cond), - }]); - // perm.view_ssn=true → !true=false → redact skipped → Pass. - assert_eq!( - run_pipeline(&p, &json!("123-45-6789"), &bag).await, - FieldOutcome::Pass, - ); - } - - #[tokio::test] - async fn pipeline_omit_short_circuits() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![ - Stage::Omit, - // This stage should never run. - Stage::Type(TypeCheck::Int), - ]); - assert_eq!( - run_pipeline(&p, &json!("anything"), &bag).await, - FieldOutcome::Omit - ); - } - - #[tokio::test] - async fn pipeline_range_validator() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![ - Stage::Type(TypeCheck::Int), - Stage::Range { - min: Some(0), - max: Some(1_000_000), - }, - ]); - assert_eq!( - run_pipeline(&p, &json!(500_000), &bag).await, - FieldOutcome::Pass - ); - // Above max → deny. - match run_pipeline(&p, &json!(2_000_000), &bag).await { - FieldOutcome::Deny { - reason, - stage_index, - } => { - assert!(reason.contains("outside")); - assert_eq!(stage_index, 1); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_length_validator() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Length { - min: None, - max: Some(5), - }]); - assert_eq!( - run_pipeline(&p, &json!("hi"), &bag).await, - FieldOutcome::Pass - ); - assert!(matches!( - run_pipeline(&p, &json!("too long"), &bag).await, - FieldOutcome::Deny { .. }, - )); - } - - #[tokio::test] - async fn pipeline_enum_validator() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Enum { - values: vec!["low".into(), "medium".into(), "high".into()], - }]); - assert_eq!( - run_pipeline(&p, &json!("medium"), &bag).await, - FieldOutcome::Pass - ); - assert!(matches!( - run_pipeline(&p, &json!("extreme"), &bag).await, - FieldOutcome::Deny { .. }, - )); - } - - #[tokio::test] - async fn pipeline_uuid_validator() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]); - assert_eq!( - run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await, - FieldOutcome::Pass, - ); - assert!(matches!( - run_pipeline(&p, &json!("not-a-uuid"), &bag).await, - FieldOutcome::Deny { .. }, - )); - } - - #[tokio::test] - async fn pipeline_hash_replaces_value() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Hash]); - match run_pipeline(&p, &json!("secret"), &bag).await { - FieldOutcome::Replace(v) => { - let s = v.as_str().unwrap(); - assert!(s.starts_with("hash:")); - assert_eq!(s.len(), "hash:".len() + 16); - }, - other => panic!("expected Replace, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_validate_named_denies_at_runtime() { - // `validate(name)` is unimplemented in this build. The parser - // rejects it at compile time; this test exercises the runtime - // defense-in-depth path for IR built programmatically. The - // deny message points operators at the working alternatives - // (`regex(...)` / `plugin(...)`). - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![ - Stage::Type(TypeCheck::Str), - Stage::Validate { - name: "ssn_format".into(), - }, - Stage::Mask { keep_last: 4 }, - ]); - match run_pipeline(&p, &json!("123-45-6789"), &bag).await { - FieldOutcome::Deny { - reason, - stage_index, - } => { - assert_eq!(stage_index, 1, "validate stage is at index 1"); - assert!( - reason.contains("not implemented"), - "deny reason should explain that validate is unimplemented: {reason}", - ); - assert!( - reason.contains("regex") || reason.contains("plugin"), - "deny reason should point at alternatives: {reason}", - ); - }, - other => panic!("expected Deny on validate(...) stage, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_validator_short_circuits_before_transform() { - // If the validator fails, the transform never runs. - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![ - Stage::Type(TypeCheck::Int), // will fail on a string - Stage::Mask { keep_last: 4 }, - ]); - match run_pipeline(&p, &json!("hello"), &bag).await { - FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0), - other => panic!("expected Deny at stage 0, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_regex_match_passes() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Regex { - pattern: r"^\d{3}-\d{2}-\d{4}$".into(), - }]); - assert_eq!( - run_pipeline(&p, &json!("123-45-6789"), &bag).await, - FieldOutcome::Pass - ); - } - - #[tokio::test] - async fn pipeline_regex_no_match_denies() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Regex { - pattern: r"^\d{3}-\d{2}-\d{4}$".into(), - }]); - match run_pipeline(&p, &json!("not an ssn"), &bag).await { - FieldOutcome::Deny { - reason, - stage_index, - } => { - assert!(reason.contains("did not match")); - assert_eq!(stage_index, 0); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_regex_invalid_pattern_denies() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Regex { - pattern: "(unclosed".into(), - }]); - match run_pipeline(&p, &json!("anything"), &bag).await { - FieldOutcome::Deny { reason, .. } => { - assert!(reason.contains("invalid regex")); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_regex_non_string_denies() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Regex { - pattern: r"^\d+$".into(), - }]); - match run_pipeline(&p, &json!(42), &bag).await { - FieldOutcome::Deny { reason, .. } => { - assert!(reason.contains("requires string")); - }, - other => panic!("expected Deny on non-string regex input, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_taint_records_event() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![ - Stage::Type(TypeCheck::Str), - Stage::Taint { - label: "PII".into(), - scopes: vec![TaintScope::Session], - }, - Stage::Mask { keep_last: 4 }, - ]); - let result = evaluate_pipeline( - &p, - &json!("123-45-6789"), - &bag, - &null_pipe_plugins(), - "test_field", - crate::step::DispatchPhase::Pre, - ) - .await; - assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789"))); - assert_eq!( - result.taints, - vec![TaintEvent { - label: "PII".into(), - scopes: vec![TaintScope::Session], - }] - ); - } - - #[tokio::test] - async fn pipeline_scan_pii_detect_emits_taint() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Scan { - kind: ScanKind::PiiDetect, - }]); - let result = evaluate_pipeline( - &p, - &json!("some text"), - &bag, - &null_pipe_plugins(), - "test_field", - crate::step::DispatchPhase::Pre, - ) - .await; - // PII detect: value unchanged, one taint event emitted. - assert_eq!(result.outcome, FieldOutcome::Pass); - assert_eq!( - result.taints, - vec![TaintEvent { - label: "PII".into(), - scopes: vec![TaintScope::Session], - }] - ); - } - - #[tokio::test] - async fn pipeline_scan_pii_redact_replaces_and_taints() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Scan { - kind: ScanKind::PiiRedact, - }]); - let result = evaluate_pipeline( - &p, - &json!("123-45-6789"), - &bag, - &null_pipe_plugins(), - "test_field", - crate::step::DispatchPhase::Pre, - ) - .await; - assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]"))); - assert_eq!(result.taints.len(), 1); - assert_eq!(result.taints[0].label, "PII"); - } - - #[tokio::test] - async fn pipeline_scan_injection_emits_injection_taint() { - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Scan { - kind: ScanKind::InjectionScan, - }]); - let result = evaluate_pipeline( - &p, - &json!("user input"), - &bag, - &null_pipe_plugins(), - "test_field", - crate::step::DispatchPhase::Pre, - ) - .await; - assert_eq!(result.outcome, FieldOutcome::Pass); - assert_eq!(result.taints[0].label, "injection"); - } - - #[tokio::test] - async fn pipeline_deny_does_not_accumulate_later_taints() { - // Pipeline halts at the first failing validator; taints emitted - // before the failure stick, taints after do not. - let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![ - Stage::Taint { - label: "before".into(), - scopes: vec![TaintScope::Session], - }, - Stage::Type(TypeCheck::Int), // fails on string input - Stage::Taint { - label: "after".into(), - scopes: vec![TaintScope::Session], - }, - ]); - let result = evaluate_pipeline( - &p, - &json!("hello"), - &bag, - &null_pipe_plugins(), - "test_field", - crate::step::DispatchPhase::Pre, - ) - .await; - assert!(matches!(result.outcome, FieldOutcome::Deny { .. })); - assert_eq!( - result.taints, - vec![TaintEvent { - label: "before".into(), - scopes: vec![TaintScope::Session], - }] - ); - } - - /// Pipe-context plugin invoker that returns canned outcomes by name. - struct PipePlugin { - outcomes: std::collections::HashMap, - } - #[async_trait] - impl PluginInvoker for PipePlugin { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - self.outcomes - .get(name) - .cloned() - .ok_or_else(|| PluginError::NotFound(name.into())) - } - } - - #[tokio::test] - async fn pipeline_plugin_allow_continues() { - let mut bag = AttributeBag::new(); - let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { - outcomes: std::collections::HashMap::from([( - "noop".to_string(), - PluginOutcome::allow(), - )]), - }); - let p = make_pipeline(vec![ - Stage::Type(TypeCheck::Str), - Stage::Plugin { - name: "noop".into(), - }, - Stage::Mask { keep_last: 4 }, - ]); - let result = evaluate_pipeline( - &p, - &json!("123-45-6789"), - &bag, - &plugins, - "compensation", - crate::step::DispatchPhase::Pre, - ) - .await; - assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789"))); - assert!(result.taints.is_empty()); - } - - #[tokio::test] - async fn pipeline_plugin_can_replace_value() { - let mut bag = AttributeBag::new(); - let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { - outcomes: std::collections::HashMap::from([( - "scrubber".to_string(), - PluginOutcome { - decision: Decision::Allow, - taints: vec![TaintEvent { - label: "PII".to_string(), - scopes: vec![TaintScope::Session], - }], - modified_value: Some(json!("***scrubbed***")), - }, - )]), - }); - let p = make_pipeline(vec![Stage::Plugin { - name: "scrubber".into(), - }]); - let result = evaluate_pipeline( - &p, - &json!("sensitive data"), - &bag, - &plugins, - "notes", - crate::step::DispatchPhase::Pre, - ) - .await; - assert_eq!( - result.outcome, - FieldOutcome::Replace(json!("***scrubbed***")) - ); - assert_eq!( - result.taints, - vec![TaintEvent { - label: "PII".into(), - scopes: vec![TaintScope::Session], - }] - ); - } - - #[tokio::test] - async fn pipeline_plugin_deny_halts() { - let mut bag = AttributeBag::new(); - let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { - outcomes: std::collections::HashMap::from([( - "guard".to_string(), - PluginOutcome { - decision: Decision::Deny { - reason: Some("policy violation".into()), - rule_source: "guard".into(), - }, - taints: vec![], - modified_value: None, - }, - )]), - }); - let p = make_pipeline(vec![ - Stage::Plugin { - name: "guard".into(), - }, - // Should never run. - Stage::Mask { keep_last: 4 }, - ]); - let result = evaluate_pipeline( - &p, - &json!("data"), - &bag, - &plugins, - "payload", - crate::step::DispatchPhase::Pre, - ) - .await; - match result.outcome { - FieldOutcome::Deny { - reason, - stage_index, - } => { - assert_eq!(reason, "policy violation"); - assert_eq!(stage_index, 0); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn pipeline_plugin_missing_fails_closed() { - let mut bag = AttributeBag::new(); - let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { - outcomes: Default::default(), - }); - let p = make_pipeline(vec![Stage::Plugin { - name: "missing".into(), - }]); - let result = evaluate_pipeline( - &p, - &json!("data"), - &bag, - &plugins, - "payload", - crate::step::DispatchPhase::Pre, - ) - .await; - match result.outcome { - FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")), - other => panic!("expected Deny on missing plugin, got {:?}", other), - } - } - - #[test] - fn exists_distinguishes_missing_from_falsy() { - let mut bag = AttributeBag::new(); - bag.set("args.flag", false); - // Key is present with a falsy value — IsTrue says false, Exists says true. - assert!(!eval_condition( - &Condition::IsTrue { - key: "args.flag".into() - }, - &bag - )); - assert!(eval_condition( - &Condition::Exists { - key: "args.flag".into() - }, - &bag - )); - // Missing key — Exists is false. - assert!(!eval_condition( - &Condition::Exists { - key: "args.nonexistent".into() - }, - &bag - )); - } - - #[test] - fn in_set_member_and_non_member() { - let mut bag = AttributeBag::new(); - bag.set("subject.type", "user"); - bag.set( - "allowed_types", - std::collections::HashSet::from(["user".to_string(), "service".to_string()]), - ); - - assert!(eval_condition( - &Condition::InSet { - value_key: "subject.type".into(), - set_key: "allowed_types".into(), - negate: false, - }, - &bag - )); - - bag.set("subject.type", "agent"); - assert!(!eval_condition( - &Condition::InSet { - value_key: "subject.type".into(), - set_key: "allowed_types".into(), - negate: false, - }, - &bag - )); - } - - #[test] - fn in_set_negate() { - let mut bag = AttributeBag::new(); - bag.set("subject.type", "agent"); - bag.set( - "blocked_types", - std::collections::HashSet::from(["service".to_string()]), - ); - - // agent is not in blocked_types → not in → true - assert!(eval_condition( - &Condition::InSet { - value_key: "subject.type".into(), - set_key: "blocked_types".into(), - negate: true, - }, - &bag - )); - } - - #[test] - fn in_set_missing_keys_resolve_to_false() { - let mut bag = AttributeBag::new(); - // Both missing → in = false → not in = true (missing→false - // applies to the underlying `in` lookup; negate flips it). - assert!(!eval_condition( - &Condition::InSet { - value_key: "x".into(), - set_key: "y".into(), - negate: false, - }, - &bag - )); - assert!(eval_condition( - &Condition::InSet { - value_key: "x".into(), - set_key: "y".into(), - negate: true, - }, - &bag - )); - } - - #[test] - fn always_evaluates_true() { - let mut bag = AttributeBag::new(); - assert!(eval_expression(&Expression::Always, &bag)); - } - - #[test] - fn always_rule_unconditional_deny() { - let mut bag = AttributeBag::new(); - let r = Rule { - condition: Expression::Always, - effects: vec![Effect::Deny { - reason: Some("unconditional".into()), - code: None, - }], - source: "test".into(), - }; - match evaluate_rules(&[r], &bag) { - Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")), - d => panic!("expected Deny, got {:?}", d), - } - } - - use crate::step::{ - PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation, - PluginInvoker, PluginOutcome, - }; - use async_trait::async_trait; - - /// PDP resolver that returns the decision baked into it. Doesn't - /// inspect call.args — tests assert on call.dialect / on the decision - /// flow, not on Cedar/OPA-specific arg parsing. - struct FakePdp { - decision: Decision, - } - #[async_trait] - impl PdpResolver for FakePdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: self.decision.clone(), - diagnostics: vec![], - }) - } - } - - /// PDP resolver that returns an error — exercises fail-closed path. - struct ErroringPdp; - #[async_trait] - impl PdpResolver for ErroringPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Err(PdpError::Dispatch("simulated PDP outage".into())) - } - } - - /// Elicitation invoker that dispatches fine but resolves to a human - /// *denial* — exercises the "genuine no halts unconditionally" path. - struct DenyingElicitor; - #[async_trait] - impl crate::step::ElicitationInvoker for DenyingElicitor { - async fn dispatch( - &self, - _step: &crate::step::ElicitStep, - resolved_from: &str, - ) -> Result { - Ok(crate::step::ElicitationDispatch { - id: "deny-1".into(), - approver: Some(resolved_from.to_string()), - intent_id: None, - expires_at: None, - }) - } - async fn check( - &self, - _step: &crate::step::ElicitStep, - _id: &str, - ) -> Result { - Ok(crate::step::ElicitationStatus::Resolved { - outcome: crate::step::ElicitationOutcome::Denied, - }) - } - async fn validate( - &self, - _step: &crate::step::ElicitStep, - _id: &str, - ) -> Result { - // Never reached on a denial — the arm halts before validate. - unreachable!("validate must not run on a denied elicitation") - } - } - - /// Elicitation invoker that dispatches fine but never resolves — - /// every `check` returns Pending. Exercises the suspend path. - struct StillPendingElicitor; - #[async_trait] - impl crate::step::ElicitationInvoker for StillPendingElicitor { - async fn dispatch( - &self, - _step: &crate::step::ElicitStep, - resolved_from: &str, - ) -> Result { - Ok(crate::step::ElicitationDispatch { - id: "pending-1".into(), - approver: Some(resolved_from.to_string()), - intent_id: Some("intent-xyz".into()), - expires_at: Some("2026-12-31T00:00:00Z".into()), - }) - } - async fn check( - &self, - _step: &crate::step::ElicitStep, - _id: &str, - ) -> Result { - Ok(crate::step::ElicitationStatus::Pending) - } - async fn validate( - &self, - _step: &crate::step::ElicitStep, - _id: &str, - ) -> Result { - unreachable!("validate must not run while pending") - } - } - - /// Plugin invoker keyed by name → outcome. - struct FakePlugin { - decisions: std::collections::HashMap, - } - #[async_trait] - impl PluginInvoker for FakePlugin { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - match self.decisions.get(name) { - Some(d) => Ok(PluginOutcome { - decision: d.clone(), - taints: vec![], - modified_value: None, - }), - None => Err(PluginError::NotFound(name.into())), - } - } - } - - /// Null invoker — fails any plugin call (for PDP-only tests). - struct NullPlugins; - #[async_trait] - impl PluginInvoker for NullPlugins { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - Err(PluginError::NotFound(name.into())) - } - } - - fn pdp_step(decision_diagnostic_label: &str) -> Effect { - Effect::Pdp { - call: PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::Value::String(decision_diagnostic_label.into()), - }, - on_deny: vec![], - on_allow: vec![], - } - } - - #[tokio::test] - async fn steps_rule_only_path() { - let mut bag = AttributeBag::new(); - let steps = vec![Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "test".into(), - }]; - let r = evaluate_effects( - &steps, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - } - - #[tokio::test] - async fn pdp_allow_continues() { - let mut bag = AttributeBag::new(); - let steps = vec![pdp_step("dummy")]; - let pdp: Arc = Arc::new(FakePdp { - decision: Decision::Allow, - }); - assert_eq!( - evaluate_effects( - &steps, - &mut bag, - &pdp, - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null) - ) - .await - .decision, - Decision::Allow, - ); - } - - #[tokio::test] - async fn pdp_deny_returns_deny() { - let mut bag = AttributeBag::new(); - let steps = vec![pdp_step("dummy")]; - let pdp: Arc = Arc::new(FakePdp { - decision: Decision::Deny { - reason: Some("forbidden".into()), - rule_source: "pdp".into(), - }, - }); - match evaluate_effects( - &steps, - &mut bag, - &pdp, - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")), - d => panic!("expected Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn pdp_on_deny_reaction_can_override_reason() { - // PDP denies, on_deny reaction includes a more specific deny rule that - // fires before the PDP's deny is returned. - let mut bag = AttributeBag::new(); - let steps = vec![Effect::Pdp { - call: PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::Value::Null, - }, - on_deny: vec![Effect::When { - condition: Expression::Always, - body: vec![Effect::Deny { - reason: Some("reaction took over".into()), - code: None, - }], - source: "on_deny[0]".into(), - }], - on_allow: vec![], - }]; - let pdp: Arc = Arc::new(FakePdp { - decision: Decision::Deny { - reason: Some("pdp original".into()), - rule_source: "p".into(), - }, - }); - match evaluate_effects( - &steps, - &mut bag, - &pdp, - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!(reason.as_deref(), Some("reaction took over")); - assert_eq!(rule_source, "on_deny[0]"); - }, - d => panic!("expected Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn pdp_on_allow_can_deny() { - // PDP allows, but an on_allow reaction can still deny (e.g., a - // taint check that fails). Outcome: deny. - let mut bag = AttributeBag::new(); - let steps = vec![Effect::Pdp { - call: PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::Value::Null, - }, - on_deny: vec![], - on_allow: vec![Effect::When { - condition: Expression::Always, - body: vec![Effect::Deny { - reason: Some("reaction veto".into()), - code: None, - }], - source: "on_allow[0]".into(), - }], - }]; - let pdp: Arc = Arc::new(FakePdp { - decision: Decision::Allow, - }); - match evaluate_effects( - &steps, - &mut bag, - &pdp, - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")), - d => panic!("expected Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn pdp_error_is_fail_closed() { - let mut bag = AttributeBag::new(); - let steps = vec![pdp_step("dummy")]; - match evaluate_effects( - &steps, - &mut bag, - &(Arc::new(ErroringPdp) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { reason, .. } => { - assert!(reason.unwrap().contains("PDP error")); - }, - d => panic!("expected Deny on PDP error, got {:?}", d), - } - } - - #[tokio::test] - async fn plugin_allow_continues_deny_halts() { - let mut bag = AttributeBag::new(); - let plugins: std::sync::Arc = std::sync::Arc::new(FakePlugin { - decisions: std::collections::HashMap::from([ - ("ok_plugin".to_string(), Decision::Allow), - ( - "blocking_plugin".to_string(), - Decision::Deny { - reason: Some("rate limit hit".into()), - rule_source: "plugin".into(), - }, - ), - ]), - }); - - let allow_only = vec![Effect::Plugin { - name: "ok_plugin".into(), - }]; - assert_eq!( - evaluate_effects( - &allow_only, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow - }) as Arc), - &plugins, - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null) - ) - .await - .decision, - Decision::Allow, - ); - - let with_deny = vec![ - Effect::Plugin { - name: "ok_plugin".into(), - }, - Effect::Plugin { - name: "blocking_plugin".into(), - }, - ]; - match evaluate_effects( - &with_deny, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &plugins, - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")), - d => panic!("expected Deny from blocking_plugin, got {:?}", d), - } - } - - #[tokio::test] - async fn plugin_error_is_fail_closed() { - let mut bag = AttributeBag::new(); - let plugins: std::sync::Arc = std::sync::Arc::new(FakePlugin { - decisions: Default::default(), - }); - let steps = vec![Effect::Plugin { - name: "missing".into(), - }]; - match evaluate_effects( - &steps, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &plugins, - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { - reason, - rule_source, - } => { - assert!(reason.unwrap().contains("missing")); - assert!(rule_source.contains("missing")); - }, - d => panic!("expected Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn taint_step_always_continues_and_accumulates() { - let mut bag = AttributeBag::new(); - let steps = vec![ - Effect::Taint { - label: "PII".into(), - scopes: vec![crate::pipeline::TaintScope::Session], - }, - // A later rule should still fire — taint doesn't short-circuit. - Effect::When { - condition: Expression::Always, - body: vec![Effect::Deny { - reason: Some("after taint".into()), - code: None, - }], - source: "p[1]".into(), - }, - ]; - let eval = evaluate_effects( - &steps, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await; - match eval.decision { - Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")), - d => panic!("expected Deny from rule after Taint, got {:?}", d), - } - // Step::Taint should have been accumulated into the phase's taints - // before the deny landed — audit needs to see what tainted before - // the policy halted. - assert_eq!(eval.taints.len(), 1); - assert_eq!(eval.taints[0].label, "PII"); - assert_eq!( - eval.taints[0].scopes, - vec![crate::pipeline::TaintScope::Session] - ); - } - - // ----- restrict effect accumulation ----- - - fn restrict_regions(regions: &[&str]) -> Effect { - use crate::constraint::{RestrictSpec, StringSetSpec}; - Effect::Restrict { - spec: RestrictSpec { - allow_regions: Some(StringSetSpec::Literal( - regions.iter().map(|s| s.to_string()).collect(), - )), - ..Default::default() - }, - } - } - - async fn eval(effects: &[Effect], bag: &mut AttributeBag) -> StepsEvaluation { - evaluate_effects( - effects, - bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - } - - #[tokio::test] - async fn restrict_accumulates_and_continues() { - // `restrict` never halts; a later rule still runs, and the - // constraint lands in `constraints`. - let mut bag = AttributeBag::new(); - let effects = vec![restrict_regions(&["eu"]), Effect::Allow]; - let e = eval(&effects, &mut bag).await; - assert_eq!(e.decision, Decision::Allow); - assert_eq!(e.constraints.len(), 1); - assert_eq!( - e.constraints[0].allow_regions.as_deref(), - Some(&["eu".to_string()][..]) - ); - } - - #[tokio::test] - async fn restrict_accumulates_even_when_phase_denies() { - // Same discipline as taint — a constraint emitted before a deny - // still surfaces (audit / the host may still want it). - let mut bag = AttributeBag::new(); - let effects = vec![ - restrict_regions(&["eu"]), - Effect::Deny { - reason: Some("later".into()), - code: None, - }, - ]; - let e = eval(&effects, &mut bag).await; - assert!(matches!(e.decision, Decision::Deny { .. })); - assert_eq!(e.constraints.len(), 1); - } - - #[tokio::test] - async fn restrict_gated_by_when_only_fires_when_true() { - // The composition-layer gate: constraint emits only if `when` holds. - let gated = |key: &str| Effect::When { - condition: Expression::Condition(Condition::IsTrue { key: key.into() }), - body: vec![restrict_regions(&["eu"])], - source: "p[0]".into(), - }; - - let mut off = AttributeBag::new(); - assert!(eval(&[gated("eu_resident")], &mut off) - .await - .constraints - .is_empty()); - - let mut on = AttributeBag::new(); - on.set("eu_resident", true); - assert_eq!( - eval(&[gated("eu_resident")], &mut on) - .await - .constraints - .len(), - 1 - ); - } - - #[tokio::test] - async fn restrict_inside_parallel_merges_back() { - // A `restrict` in one parallel branch merges into the outer - // accumulator, alongside a sibling branch's work. - let mut bag = AttributeBag::new(); - let effects = vec![Effect::Parallel(vec![ - Effect::Allow, - restrict_regions(&["eu"]), - ])]; - let e = eval(&effects, &mut bag).await; - assert_eq!(e.decision, Decision::Allow); - assert_eq!(e.constraints.len(), 1); - assert_eq!( - e.constraints[0].allow_regions.as_deref(), - Some(&["eu".to_string()][..]) - ); - } - - fn restrict_allow_models_ref(path: &str) -> Effect { - use crate::constraint::{RestrictSpec, StringSetSpec}; - Effect::Restrict { - spec: RestrictSpec { - allow_models: Some(StringSetSpec::Ref(path.to_string())), - ..Default::default() - }, - } - } - - #[tokio::test] - async fn restrict_ref_resolves_from_data_tree() { - // A `data.*` reference resolves the caller's allow-list from the - // static tree at eval time — one rule, per-caller value. - let mut bag = AttributeBag::new(); - bag.set("subject.id", "support-bot"); - bag.set( - "data.agents.support-bot.allowed_models", - std::collections::HashSet::from(["vllm/*".to_string(), "anthropic/*".to_string()]), - ); - let effects = vec![restrict_allow_models_ref( - "data.agents[subject.id].allowed_models", - )]; - let e = eval(&effects, &mut bag).await; - assert_eq!(e.constraints.len(), 1); - // Resolved to the tree's set (sorted). - assert_eq!( - e.constraints[0].allow_models.as_deref(), - Some(&["anthropic/*".to_string(), "vllm/*".to_string()][..]) - ); - } - - #[tokio::test] - async fn restrict_ref_picks_up_different_caller() { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "research-bot"); - bag.set( - "data.agents.research-bot.allowed_models", - std::collections::HashSet::from(["openai/*".to_string()]), - ); - let effects = vec![restrict_allow_models_ref( - "data.agents[subject.id].allowed_models", - )]; - let e = eval(&effects, &mut bag).await; - assert_eq!( - e.constraints[0].allow_models.as_deref(), - Some(&["openai/*".to_string()][..]) - ); - } - - #[tokio::test] - async fn restrict_ref_absent_resolves_to_empty_fail_closed() { - // No subject.id / no tree entry → the allow-list resolves to the - // empty set: a real (impossible) constraint that the host's - // on_empty then decides, never silently unconstrained. - let mut bag = AttributeBag::new(); - let effects = vec![restrict_allow_models_ref( - "data.agents[subject.id].allowed_models", - )]; - let e = eval(&effects, &mut bag).await; - assert_eq!(e.constraints.len(), 1); - assert_eq!(e.constraints[0].allow_models.as_deref(), Some(&[][..])); - } - - // ----- deny_models references fail closed ----- - - fn restrict_deny_models_ref(path: &str) -> Effect { - use crate::constraint::{RestrictSpec, StringSetSpec}; - Effect::Restrict { - spec: RestrictSpec { - deny_models: Some(StringSetSpec::Ref(path.to_string())), - ..Default::default() - }, - } - } - - fn restrict_deny_models_literal(models: &[&str]) -> Effect { - use crate::constraint::{RestrictSpec, StringSetSpec}; - Effect::Restrict { - spec: RestrictSpec { - deny_models: Some(StringSetSpec::Literal( - models.iter().map(|s| s.to_string()).collect(), - )), - ..Default::default() - }, - } - } - - #[tokio::test] - async fn restrict_deny_ref_resolves_and_accumulates() { - // A deny-list `data.*` reference that resolves lands in the - // constraint like any other field — refs stay supported on deny. - let mut bag = AttributeBag::new(); - bag.set("subject.id", "support-bot"); - bag.set( - "data.agents.support-bot.banned_models", - std::collections::HashSet::from(["openai/*".to_string()]), - ); - let effects = vec![restrict_deny_models_ref( - "data.agents[subject.id].banned_models", - )]; - let e = eval(&effects, &mut bag).await; - assert_eq!(e.decision, Decision::Allow); - assert_eq!(e.constraints.len(), 1); - assert_eq!(e.constraints[0].deny_models, vec!["openai/*".to_string()]); - } - - #[tokio::test] - async fn restrict_deny_ref_unresolvable_denies() { - // The fail-closed fix: a deny-list reference that can't resolve - // (missing subject.id / no tree entry) denies the request rather - // than routing with an empty — and thus fail-open — deny-list. - let mut bag = AttributeBag::new(); - let effects = vec![restrict_deny_models_ref( - "data.agents[subject.id].banned_models", - )]; - let e = eval(&effects, &mut bag).await; - assert!( - matches!(e.decision, Decision::Deny { .. }), - "got {:?}", - e.decision - ); - assert!(e.constraints.is_empty()); - } - - #[tokio::test] - async fn restrict_deny_ref_shape_mismatch_denies() { - // A deny reference pointing at a non-set value (here an integer) is a - // shape mismatch — unresolved, so it denies rather than silently - // vanishing into an empty deny-list. - let mut bag = AttributeBag::new(); - bag.set("subject.id", "support-bot"); - bag.set("data.agents.support-bot.banned_models", 7i64); - let effects = vec![restrict_deny_models_ref( - "data.agents[subject.id].banned_models", - )]; - let e = eval(&effects, &mut bag).await; - assert!( - matches!(e.decision, Decision::Deny { .. }), - "got {:?}", - e.decision - ); - } - - #[tokio::test] - async fn restrict_deny_literal_accumulates() { - // A literal deny-list has no resolution step, so it can never fail - // open — it simply accumulates. - let mut bag = AttributeBag::new(); - let e = eval(&[restrict_deny_models_literal(&["deprecated/*"])], &mut bag).await; - assert_eq!(e.decision, Decision::Allow); - assert_eq!(e.constraints.len(), 1); - assert_eq!( - e.constraints[0].deny_models, - vec!["deprecated/*".to_string()] - ); - } - - // ----- E2: FieldOp end-to-end through evaluate_steps ----- - - #[tokio::test] - async fn field_op_in_do_redacts_args_during_pre_phase() { - // Sketches the demo case: when condition holds, redact args.ssn - // — verifies the dispatcher walks effects, lifts the FieldOp - // out, and rewrites the payload. - let mut bag = AttributeBag::new(); - // Predicate is the rule's `when:`; here we make it always true. - let stages = vec![Stage::Redact { condition: None }]; - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::FieldOp { - path: "args.ssn".into(), - stages, - }], - source: "demo.policy[0]".into(), - }; - let steps = vec![Effect::from(rule)]; - let mut payload = crate::route::RoutePayload::new(json!({ - "ssn": "123-45-6789", - "name": "Jane", - })); - - let eval = evaluate_effects( - &steps, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - - assert_eq!(eval.decision, Decision::Allow); - assert!(eval.args_modified, "FieldOp should flag args_modified"); - // The ssn field should now read `[REDACTED]` (the stock value - // the Stage::Redact applier writes when no when-clause is set). - assert_eq!( - payload.args.get("ssn").and_then(|v| v.as_str()), - Some("[REDACTED]") - ); - // Other fields untouched. - assert_eq!( - payload.args.get("name").and_then(|v| v.as_str()), - Some("Jane") - ); - } - - /// A plugin stage learns which field it's operating on from the - /// invocation's `name`. That name is relative to the args / result - /// root at every call site, so an invoker can look the field up in - /// its own payload projection without having to strip a prefix — - /// which it couldn't do safely anyway, since `args` is a legal - /// argument name. - #[tokio::test] - async fn field_op_reports_a_root_relative_field_name_to_plugins() { - /// Captures the field name the pipeline passed down. - struct NameRecorder { - seen: std::sync::Mutex>, - } - #[async_trait] - impl PluginInvoker for NameRecorder { - async fn invoke( - &self, - _name: &str, - _bag: &AttributeBag, - invocation: PluginInvocation<'_>, - ) -> Result { - if let PluginInvocation::Field { name, .. } = invocation { - self.seen.lock().unwrap().push(name.to_string()); - } - Ok(PluginOutcome::allow()) - } - } - - let recorder = Arc::new(NameRecorder { - seen: std::sync::Mutex::new(Vec::new()), - }); - let plugins: Arc = recorder.clone(); - - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::FieldOp { - path: "args.user.ssn".into(), - stages: vec![Stage::Plugin { - name: "scrubber".into(), - }], - }], - source: "demo.policy[0]".into(), - }; - let mut bag = AttributeBag::new(); - let mut payload = crate::route::RoutePayload::new(json!({ - "user": {"ssn": "123-45-6789"}, - })); - - let _ = evaluate_effects( - &[Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &plugins, - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - - assert_eq!( - recorder.seen.lock().unwrap().as_slice(), - ["user.ssn".to_string()], - "the `args.` prefix belongs to the config path, not the field name" - ); - } - - #[tokio::test] - async fn field_op_targeting_result_in_pre_phase_is_skipped() { - // A `result.X | ...` op encountered during the Pre phase is a - // no-op — the result hasn't been produced yet. Same rule body - // can be reused across phases without branching. - let mut bag = AttributeBag::new(); - let stages = vec![Stage::Redact { condition: None }]; - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::FieldOp { - path: "result.ssn".into(), - stages, - }], - source: "demo.policy[0]".into(), - }; - let mut payload = crate::route::RoutePayload::new(json!({})); - let eval = evaluate_effects( - &vec![Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - assert_eq!(eval.decision, Decision::Allow); - assert!(!eval.args_modified); - assert!(!eval.result_modified); - } - - #[tokio::test] - async fn field_op_with_invalid_path_denies() { - // Path missing the `args.` / `result.` prefix is an author bug - // — fail closed with a clear violation rather than silently - // doing nothing. - let mut bag = AttributeBag::new(); - let stages = vec![Stage::Redact { condition: None }]; - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::FieldOp { - path: "ssn".into(), // missing prefix - stages, - }], - source: "demo.policy[0]".into(), - }; - let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"})); - let eval = evaluate_effects( - &vec![Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - match eval.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert!(reason.unwrap_or_default().contains("must start with")); - assert_eq!(rule_source, "demo.policy[0]"); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn sequential_runs_effects_in_order_until_deny() { - // A Sequential block runs each effect in order. Allow-only - // effects pass through; the first Deny halts the rest of the - // sequential body AND the parent step. - let mut bag = AttributeBag::new(); - let mut payload = crate::route::RoutePayload::new(json!({})); - - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::Sequential(vec![ - Effect::Allow, - Effect::Deny { - reason: Some("blocked by sequential".into()), - code: Some("seq.test".into()), - }, - Effect::Allow, // unreachable - ])], - source: "test.policy[0]".into(), - }; - - let eval = evaluate_effects( - &vec![Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - - match eval.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!(reason.as_deref(), Some("blocked by sequential")); - // The `code` override on the effect won — `seq.test` - // rather than the rule's `test.policy[0]` source. - assert_eq!(rule_source, "seq.test"); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn parallel_allows_when_no_branch_denies() { - // Both branches are no-op Allow → overall Continue → route Allow. - let mut bag = AttributeBag::new(); - let mut payload = crate::route::RoutePayload::new(json!({})); - - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::Parallel(vec![ - Effect::Allow, - Effect::Taint { - label: "audit_branch".into(), - scopes: vec![crate::pipeline::TaintScope::Session], - }, - ])], - source: "test.policy[0]".into(), - }; - - let eval = evaluate_effects( - &vec![Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - - assert_eq!(eval.decision, Decision::Allow); - // Taints from parallel branches accumulate into the outer. - assert_eq!(eval.taints.len(), 1); - assert_eq!(eval.taints[0].label, "audit_branch"); - } - - #[tokio::test] - async fn parallel_denies_when_any_branch_denies() { - // One Allow, one Deny — overall Deny. - let mut bag = AttributeBag::new(); - let mut payload = crate::route::RoutePayload::new(json!({})); - - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::Parallel(vec![ - Effect::Allow, - Effect::Deny { - reason: Some("branch 1 denied".into()), - code: None, - }, - ])], - source: "test.policy[0]".into(), - }; - - let eval = evaluate_effects( - &vec![Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - - match eval.decision { - Decision::Deny { reason, .. } => { - assert_eq!(reason.as_deref(), Some("branch 1 denied")); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - #[tokio::test] - async fn parallel_picks_first_index_halt_not_first_to_complete() { - // When two branches both deny, the one with the lower index - // in the effects list wins — not the one that physically - // finishes first. - let mut bag = AttributeBag::new(); - let mut payload = crate::route::RoutePayload::new(json!({})); - - let rule = Rule { - condition: Expression::Always, - effects: vec![Effect::Parallel(vec![ - Effect::Deny { - reason: Some("idx-0".into()), - code: None, - }, - Effect::Deny { - reason: Some("idx-1".into()), - code: None, - }, - ])], - source: "test.policy[0]".into(), - }; - - let eval = evaluate_effects( - &vec![Effect::from(rule)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &noop_elicitations(), - crate::step::DispatchPhase::Pre, - &mut payload, - ) - .await; - - match eval.decision { - Decision::Deny { reason, .. } => { - assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins"); - }, - other => panic!("expected Deny, got {:?}", other), - } - } - - /// Run a single `Effect::Elicit` against the given invoker + bag. - /// Seeds the `user.manager` attribute (the `from` these tests use) so - /// it resolves — an unresolved attribute `from` now fails closed, which - /// its own dedicated test covers. - async fn run_elicit( - effect: Effect, - elicitations: &Arc, - bag: &mut AttributeBag, - ) -> StepsEvaluation { - if bag.get_string("user.manager").is_none() { - bag.set("user.manager", "manager@corp.com"); - } - evaluate_effects( - &[effect], - bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - elicitations, - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - } - - #[tokio::test] - async fn elicit_auto_approve_allows_and_records_facts() { - let mut bag = AttributeBag::new(); - let eval = run_elicit(elicit_effect(None, None), &auto_elicitations(), &mut bag).await; - assert_eq!(eval.decision, Decision::Allow); - // Resolved facts land in the bag for downstream rules / audit. - assert_eq!(bag.get_string("elicitation.status"), Some("resolved")); - assert_eq!(bag.get_string("elicitation.outcome"), Some("approved")); - assert_eq!( - bag.get_string("elicitation.approver"), - Some("manager@corp.com") - ); - assert_eq!(bag.get_string("elicitation.intent_id"), Some("auto-intent")); - } - - #[tokio::test] - async fn elicit_scope_satisfied_allows() { - // Sufficiency check passes: amount within the approved bound. - let mut bag = AttributeBag::new(); - bag.set("args.amount", 100_i64); - let eval = run_elicit( - elicit_effect(Some("args.amount <= 25000"), None), - &auto_elicitations(), - &mut bag, - ) - .await; - assert_eq!(eval.decision, Decision::Allow); - } - - #[tokio::test] - async fn elicit_scope_unsatisfied_denies_even_when_approved() { - // The human approved, but the live args exceed scope — the runtime - // sufficiency check fails closed regardless of the genuine approval. - let mut bag = AttributeBag::new(); - bag.set("args.amount", 50_000_i64); - let eval = run_elicit( - elicit_effect(Some("args.amount <= 25000"), None), - &auto_elicitations(), - &mut bag, - ) - .await; - match eval.decision { - Decision::Deny { reason, .. } => { - assert!( - reason - .as_deref() - .unwrap_or("") - .contains("scope not satisfied"), - "got: {:?}", - reason - ); - }, - d => panic!("expected scope Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn elicit_noop_dispatch_fails_closed() { - // No channel wired → dispatch errors → fail closed (default deny). - let mut bag = AttributeBag::new(); - let eval = run_elicit(elicit_effect(None, None), &noop_elicitations(), &mut bag).await; - match eval.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert!(reason.as_deref().unwrap_or("").contains("dispatch failed")); - assert_eq!(rule_source, "route.test.policy[0]"); - }, - d => panic!("expected fail-closed Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn elicit_on_error_continue_allows_on_dispatch_failure() { - // Same noop failure, but on_error=continue lets the pipeline pass. - let mut bag = AttributeBag::new(); - let eval = run_elicit( - elicit_effect(None, Some("continue")), - &noop_elicitations(), - &mut bag, - ) - .await; - assert_eq!(eval.decision, Decision::Allow); - } - - #[tokio::test] - async fn elicit_denied_halts_regardless_of_on_error() { - // A genuine human "no" halts even with on_error=continue — on_error - // governs failures, not a valid denial. - let denier: Arc = Arc::new(DenyingElicitor); - let mut bag = AttributeBag::new(); - let eval = run_elicit(elicit_effect(None, Some("continue")), &denier, &mut bag).await; - match eval.decision { - Decision::Deny { reason, .. } => { - assert!(reason - .as_deref() - .unwrap_or("") - .contains("denied by approver")); - }, - d => panic!("expected denial Deny, got {:?}", d), - } - assert_eq!(bag.get_string("elicitation.outcome"), Some("denied")); - } - - #[tokio::test] - async fn elicit_pending_suspends_not_denies() { - // Unresolved elicitation → the phase suspends: decision stays Allow - // and a pending bundle is carried out (the host emits -32120). This - // is the key difference from the fail-closed placeholder. - let pending: Arc = Arc::new(StillPendingElicitor); - let mut bag = AttributeBag::new(); - let eval = run_elicit(elicit_effect(None, None), &pending, &mut bag).await; - - assert_eq!(eval.decision, Decision::Allow, "pending is not a deny"); - let bundle = eval.pending.expect("pending bundle carried out"); - assert_eq!(bundle.id, "pending-1"); - assert_eq!(bundle.plugin_name, "manager-approver"); - assert_eq!(bundle.approver.as_deref(), Some("manager@corp.com")); - assert_eq!(bundle.intent_id.as_deref(), Some("intent-xyz")); - assert_eq!(bundle.channel.as_deref(), Some("ciba")); - assert_eq!(bundle.expires_at.as_deref(), Some("2026-12-31T00:00:00Z")); - assert_eq!(bundle.source, "route.test.policy[0]"); - // Bag reflects the in-flight state for audit. - assert_eq!(bag.get_string("elicitation.status"), Some("pending")); - } - - #[tokio::test] - async fn elicit_pending_even_with_on_error_continue() { - // on_error governs *failures*; a genuine pending is not a failure, - // so on_error=continue must NOT collapse it into a plain allow — - // the bundle still surfaces so the host suspends. - let pending: Arc = Arc::new(StillPendingElicitor); - let mut bag = AttributeBag::new(); - let eval = run_elicit(elicit_effect(None, Some("continue")), &pending, &mut bag).await; - assert_eq!(eval.decision, Decision::Allow); - assert!( - eval.pending.is_some(), - "pending must survive on_error=continue" - ); - } - - #[tokio::test] - async fn elicit_pending_short_circuits_later_effects() { - // A pending elicitation suspends the phase: effects after it in the - // list do not run this pass. A trailing `deny` proves it — if the - // phase kept walking, the decision would be Deny, not Allow+pending. - let pending: Arc = Arc::new(StillPendingElicitor); - let mut bag = AttributeBag::new(); - bag.set("user.manager", "manager@corp.com"); - let effects = vec![elicit_effect(None, None), deny("should not run")]; - let eval = evaluate_effects( - &effects, - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &pending, - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await; - assert_eq!(eval.decision, Decision::Allow, "trailing deny must not run"); - assert!(eval.pending.is_some()); - } - - #[tokio::test] - async fn elicit_unresolved_from_attribute_fails_closed() { - // `from` is an attribute reference (`user.manager`) that isn't in - // the bag. It must fail closed BEFORE dispatch — never send the - // literal `"user.manager"` to the channel as a bogus login_hint. - let mut bag = AttributeBag::new(); // note: no user.manager seeded - let eval = evaluate_effects( - &[elicit_effect(None, None)], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &auto_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await; - match eval.decision { - Decision::Deny { reason, .. } => { - let r = reason.as_deref().unwrap_or(""); - assert!(r.contains("did not resolve"), "got: {r}"); - assert!(r.contains("user.manager"), "got: {r}"); - }, - d => panic!( - "expected fail-closed Deny on unresolved `from`, got {:?}", - d - ), - } - // Nothing was dispatched, so no elicitation id was recorded. - assert!(bag.get_string("elicitation.id").is_none()); - } - - #[tokio::test] - async fn elicit_literal_from_passes_through_unresolved() { - // A `from` that is a literal identity (an email — not an attribute - // reference) is used verbatim even when it isn't a bag key. - let mut bag = AttributeBag::new(); - let effect = Effect::Elicit(crate::step::ElicitStep { - from: "alice@corp.com".into(), - ..match elicit_effect(None, None) { - Effect::Elicit(e) => e, - _ => unreachable!(), - } - }); - let eval = evaluate_effects( - &[effect], - &mut bag, - &(Arc::new(FakePdp { - decision: Decision::Allow, - }) as Arc), - &null_plugins(), - &noop_delegations(), - &auto_elicitations(), - crate::step::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await; - assert_eq!(eval.decision, Decision::Allow); - // The literal identity flowed through as the resolved approver. - assert_eq!( - bag.get_string("elicitation.approver"), - Some("alice@corp.com") - ); - } - - #[test] - fn looks_like_attribute_ref_classification() { - // Attribute references: dotted identifier paths. - assert!(looks_like_attribute_ref("user.manager")); - assert!(looks_like_attribute_ref("claim.manager")); - assert!(looks_like_attribute_ref("user.sub")); - // Literals: emails, display names, bare tokens. - assert!(!looks_like_attribute_ref("alice@corp.com")); - assert!(!looks_like_attribute_ref("Alice Smith")); - assert!(!looks_like_attribute_ref("alice")); - assert!(!looks_like_attribute_ref("")); - } -} diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs deleted file mode 100644 index e817dc26..00000000 --- a/crates/apl-core/src/lib.rs +++ /dev/null @@ -1,52 +0,0 @@ -// Location: ./crates/apl-core/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// APL core — Authorization Policy Language compiler + evaluator. -// -// This crate is the language nucleus. It does not depend on CPEX directly; -// the bridge from cpex-core extensions into the AttributeBag lives in -// `apl-cmf`, and the `PolicyEvaluator` implementation lives in `apl-cpex`. - -#![doc = "APL — Authorization Policy Language."] - -pub mod attribute_source; -pub mod attributes; -pub mod constraint; -pub mod evaluator; -pub mod parser; -pub mod pipeline; -pub mod plugin_decl; -pub mod route; -pub mod rules; -pub mod step; - -pub use attribute_source::{AttributeError, AttributeSource, AttributeTree}; -pub use attributes::{AttributeBag, AttributeExtractor, AttributeValue}; -pub use evaluator::{ - evaluate_effects, evaluate_pipeline, evaluate_rules, Decision, FieldOutcome, PipelineEvaluation, -}; -pub use parser::{ - compile_config, compile_policy_block_value, parse_pipeline, parse_predicate, parse_rule, - CompiledConfig, ConfigYaml, ParseError, RouteYaml, -}; -pub use pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck}; -pub use plugin_decl::{ - CapsView, EffectivePlugin, PluginDeclaration, PluginOverride, PluginRegistry, -}; -pub use route::{ - evaluate_post, evaluate_pre, evaluate_route, get_dotted, RouteDecision, RoutePayload, -}; -pub use rules::{ - CompareOp, CompiledRoute, Condition, DenyResponse, Effect, Expression, Literal, Phase, - PhaseSet, Rule, -}; -pub use step::{ - delegation_bag_keys, elicitation_bag_keys, AutoApprovingElicitor, DelegateStep, - DelegationError, DelegationInvoker, DelegationOutcome, DispatchPhase, ElicitKind, ElicitStep, - ElicitationDispatch, ElicitationError, ElicitationInvoker, ElicitationOutcome, - ElicitationStatus, ElicitationValidation, NoopDelegationInvoker, NoopElicitationInvoker, - PdpCall, PdpDecision, PdpDialect, PdpError, PdpFactory, PdpResolver, PendingElicitation, - PluginError, PluginInvocation, PluginInvoker, PluginOutcome, -}; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs deleted file mode 100644 index 378b6536..00000000 --- a/crates/apl-core/src/parser.rs +++ /dev/null @@ -1,5473 +0,0 @@ -// Location: ./crates/apl-core/src/parser.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// APL parser — DSL string → IR, and YAML config → HashMap. -// -// Runs once at config load. The IR it produces is what the evaluator walks -// at request time; the parser is never on the hot path. -// -// Grammar: predicates, rules, EBNF. YAML shape: `routes:` as a map -// keyed by route_key. -// -// Scope: -// ✓ Predicate grammar: identifiers, literals, comparisons, contains, -// & | ! parens, require(...) -// ✓ Actions: deny / allow / (default deny on missing) -// ✓ YAML top-level routes: keyed map, authorization.pre_invocation / -// post_invocation blocks (flat pre_invocation:/post_invocation: too) -// ✗ Steps (cedar:(), opa(), plugin(), taint()) — rejected with clear errors -// ✗ Pipe chains in args:/result: — fields parsed, values stashed as opaque -// ✗ `in` / `not in` / `exists()` — need IR variants first; rejected -// ✗ Multi-effect do: lists, sequential:/parallel: blocks — rejected - -use std::collections::HashMap; - -use serde::Deserialize; -use thiserror::Error; - -use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck}; -use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; -use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule}; -use crate::step::{DelegateStep, ElicitKind, ElicitStep, PdpCall, PdpDialect, Step}; - -#[derive(Debug, Error)] -pub enum ParseError { - #[error("YAML parse error: {0}")] - Yaml(#[from] serde_yaml::Error), - - #[error("rule '{rule}': {msg}")] - Rule { rule: String, msg: String }, - - #[error("unsupported step `{kind}` in rule '{rule}'")] - UnsupportedStep { rule: String, kind: String }, - - #[error("predicate '{predicate}': {msg}")] - Predicate { predicate: String, msg: String }, - - #[error("in `{location}`: config field `{old}` was renamed to `{new}` — update your config")] - RenamedField { - location: String, - old: String, - new: String, - }, - - #[error( - "in `{location}`: `{phase}` is declared both nested under `authorization:` and flat on \ - the section — use one form, not both (declaring both runs the effects twice)" - )] - ConflictingAuthorizationForms { location: String, phase: String }, -} - -#[derive(Debug, Clone, PartialEq)] -enum Tok { - Ident(String), // dotted: subject.id, role.hr, authenticated - StringLit(String), - IntLit(i64), - FloatLit(f64), - BoolLit(bool), - Eq, // == - NotEq, // != - Gt, // > - GtEq, // >= - Lt, // < - LtEq, // <= - And, // & (must have surrounding spaces — caller enforces) - Or, // | - Not, // ! - LParen, - RParen, - Comma, - Contains, // keyword - Require, // keyword - Exists, // keyword - In, // keyword — set membership operator -} - -struct Lexer<'a> { - src: &'a str, - bytes: &'a [u8], - pos: usize, -} - -impl<'a> Lexer<'a> { - fn new(src: &'a str) -> Self { - Self { - src, - bytes: src.as_bytes(), - pos: 0, - } - } - - fn peek(&self) -> Option { - self.bytes.get(self.pos).copied() - } - - fn bump(&mut self) -> Option { - let b = self.peek()?; - self.pos += 1; - Some(b) - } - - fn skip_ws(&mut self) { - while let Some(b) = self.peek() { - if b.is_ascii_whitespace() { - self.pos += 1; - } else { - break; - } - } - } - - fn tokenize_all(&mut self) -> Result, ParseError> { - let mut out = Vec::new(); - loop { - self.skip_ws(); - let Some(b) = self.peek() else { - return Ok(out); - }; - - let tok = match b { - b'(' => { - self.pos += 1; - Tok::LParen - }, - b')' => { - self.pos += 1; - Tok::RParen - }, - b',' => { - self.pos += 1; - Tok::Comma - }, - b'&' => { - self.pos += 1; - Tok::And - }, - b'|' => { - self.pos += 1; - Tok::Or - }, - b'=' => { - self.pos += 1; - if self.peek() == Some(b'=') { - self.pos += 1; - Tok::Eq - } else { - return Err(self.err("expected `==`, saw `=`")); - } - }, - b'!' => { - self.pos += 1; - if self.peek() == Some(b'=') { - self.pos += 1; - Tok::NotEq - } else { - Tok::Not - } - }, - b'>' => { - self.pos += 1; - if self.peek() == Some(b'=') { - self.pos += 1; - Tok::GtEq - } else { - Tok::Gt - } - }, - b'<' => { - self.pos += 1; - if self.peek() == Some(b'=') { - self.pos += 1; - Tok::LtEq - } else { - Tok::Lt - } - }, - b'"' | b'\'' => self.lex_string(b)?, - b'-' | b'0'..=b'9' => self.lex_number()?, - b if is_ident_start(b) => self.lex_ident_or_keyword()?, - _ => return Err(self.err(&format!("unexpected char `{}`", b as char))), - }; - out.push(tok); - } - } - - fn lex_string(&mut self, quote: u8) -> Result { - self.bump(); // opening quote - let start = self.pos; - while let Some(b) = self.peek() { - if b == quote { - break; - } - self.pos += 1; - } - if self.peek() != Some(quote) { - return Err(self.err("unterminated string literal")); - } - let s = std::str::from_utf8(&self.bytes[start..self.pos]) - .map_err(|_| self.err("non-utf8 in string literal"))? - .to_string(); - self.bump(); // closing quote - Ok(Tok::StringLit(s)) - } - - fn lex_number(&mut self) -> Result { - let start = self.pos; - if self.peek() == Some(b'-') { - self.pos += 1; - } - while let Some(b) = self.peek() { - if b.is_ascii_digit() { - self.pos += 1; - } else { - break; - } - } - let mut is_float = false; - if self.peek() == Some(b'.') { - is_float = true; - self.pos += 1; - while let Some(b) = self.peek() { - if b.is_ascii_digit() { - self.pos += 1; - } else { - break; - } - } - } - let text = &self.src[start..self.pos]; - if is_float { - text.parse::() - .map(Tok::FloatLit) - .map_err(|_| self.err(&format!("bad float `{}`", text))) - } else { - text.parse::() - .map(Tok::IntLit) - .map_err(|_| self.err(&format!("bad int `{}`", text))) - } - } - - fn lex_ident_or_keyword(&mut self) -> Result { - let start = self.pos; - // An attribute path is ident-cont runs interleaved with `[...]` - // interpolation groups: `data.tenants[subject.tenant].data_region`. - // The bracket content is a nested attribute key the evaluator - // resolves at eval time — the lexer only delimits it. - let mut has_bracket = false; - loop { - while let Some(b) = self.peek() { - if is_ident_cont(b) { - self.pos += 1; - } else { - break; - } - } - if self.peek() == Some(b'[') { - has_bracket = true; - self.pos += 1; // consume `[` - let mut closed = false; - while let Some(b) = self.peek() { - self.pos += 1; - match b { - b']' => { - closed = true; - break; - }, - b'[' => return Err(self.err("nested `[` in attribute path")), - _ => {}, - } - } - if !closed { - return Err(self.err("unterminated `[` in attribute path")); - } - // Continue: more ident-cont chars or another `[...]` may follow. - } else { - break; - } - } - let s = &self.src[start..self.pos]; - // A path with an interpolation group is never a keyword. - if has_bracket { - return Ok(Tok::Ident(s.to_string())); - } - Ok(match s { - "true" => Tok::BoolLit(true), - "false" => Tok::BoolLit(false), - "contains" => Tok::Contains, - "require" => Tok::Require, - "exists" => Tok::Exists, - "in" => Tok::In, - // "not" is NOT a keyword — it only appears in the `not in` - // phrase. The parser handles that as an Ident("not") + Tok::In - // sequence in parse_identifier_predicate. - _ => Tok::Ident(s.to_string()), - }) - } - - fn err(&self, msg: &str) -> ParseError { - ParseError::Predicate { - predicate: self.src.to_string(), - msg: format!("at byte {}: {}", self.pos, msg), - } - } -} - -fn is_ident_start(b: u8) -> bool { - b.is_ascii_alphabetic() || b == b'_' -} - -fn is_ident_cont(b: u8) -> bool { - b.is_ascii_alphanumeric() || b == b'_' || b == b'.' -} - -struct PredParser<'a> { - src: &'a str, - toks: Vec, - pos: usize, -} - -impl<'a> PredParser<'a> { - fn parse(src: &'a str) -> Result { - let toks = Lexer::new(src).tokenize_all()?; - let mut p = Self { src, toks, pos: 0 }; - let expr = p.parse_or()?; - if p.pos < p.toks.len() { - return Err(p.err(&format!( - "trailing tokens after expression: {:?}", - &p.toks[p.pos..] - ))); - } - Ok(expr) - } - - fn peek(&self) -> Option<&Tok> { - self.toks.get(self.pos) - } - fn bump(&mut self) -> Option { - let t = self.toks.get(self.pos).cloned()?; - self.pos += 1; - Some(t) - } - fn err(&self, msg: &str) -> ParseError { - ParseError::Predicate { - predicate: self.src.to_string(), - msg: msg.to_string(), - } - } - - fn parse_or(&mut self) -> Result { - let mut parts = vec![self.parse_and()?]; - while matches!(self.peek(), Some(Tok::Or)) { - self.bump(); - parts.push(self.parse_and()?); - } - Ok(if parts.len() == 1 { - parts.pop().expect("parts.len() == 1 was checked above") - } else { - Expression::Or(parts) - }) - } - - fn parse_and(&mut self) -> Result { - let mut parts = vec![self.parse_unary()?]; - while matches!(self.peek(), Some(Tok::And)) { - self.bump(); - parts.push(self.parse_unary()?); - } - Ok(if parts.len() == 1 { - parts.pop().expect("parts.len() == 1 was checked above") - } else { - Expression::And(parts) - }) - } - - fn parse_unary(&mut self) -> Result { - if matches!(self.peek(), Some(Tok::Not)) { - self.bump(); - let inner = self.parse_unary()?; - return Ok(Expression::Not(Box::new(inner))); - } - self.parse_atom() - } - - fn parse_atom(&mut self) -> Result { - match self.peek() { - Some(Tok::LParen) => { - self.bump(); - let inner = self.parse_or()?; - match self.bump() { - Some(Tok::RParen) => Ok(inner), - _ => Err(self.err("expected `)`")), - } - }, - // `require(...)` is a rule-level shorthand per the DSL grammar - // (`rule = require_call | predicate ...`), not a sub-predicate. - // Trying to nest it inside `&` / `|` is a grammar error. - Some(Tok::Require) => Err(self.err( - "`require(...)` is a rule-level shorthand, not a sub-predicate \ - — use `&` / `|` / `!` over bare identifiers instead", - )), - Some(Tok::Exists) => self.parse_exists(), - Some(Tok::Ident(_)) => self.parse_identifier_predicate(), - other => Err(self.err(&format!("expected atom, got {:?}", other))), - } - } - - /// `exists()`. Returns true if the key is present - /// in the AttributeBag, regardless of value (distinct from truthiness). - fn parse_exists(&mut self) -> Result { - self.bump(); // exists - match self.bump() { - Some(Tok::LParen) => {}, - _ => return Err(self.err("expected `(` after `exists`")), - } - let key = match self.bump() { - Some(Tok::Ident(s)) => s, - other => { - return Err(self.err(&format!( - "exists(...) expects an attribute key, got {:?}", - other, - ))) - }, - }; - match self.bump() { - Some(Tok::RParen) => {}, - other => { - return Err(self.err(&format!( - "expected `)` after exists() argument, got {:?}", - other, - ))) - }, - } - Ok(Expression::Condition(Condition::Exists { key })) - } - - /// Parse a predicate that begins with an identifier: - /// - bare identifier: `authenticated` → IsTrue - /// - comparison: `delegation.depth > 2` - /// - contains: `session.labels contains "PII"` - /// - set membership: `subject.type in allowed_types` - /// - set non-membership: `subject.type not in blocked_types` - fn parse_identifier_predicate(&mut self) -> Result { - let key = match self.bump() { - Some(Tok::Ident(s)) => s, - _ => unreachable!("parse_atom dispatched here"), - }; - - // `in` and `not in` — two-key set membership. - if matches!(self.peek(), Some(Tok::In)) { - self.bump(); - return self.finish_in_set(key, false); - } - // `not in` shows up as Ident("not") + Tok::In. Treat that as a - // grammar phrase here; bare `not` outside this context is not a - // DSL keyword (use `!` for predicate negation). - if let Some(Tok::Ident(maybe_not)) = self.peek() { - if maybe_not == "not" { - let saved_pos = self.pos; - self.bump(); // consume "not" - if matches!(self.peek(), Some(Tok::In)) { - self.bump(); - return self.finish_in_set(key, true); - } - // Not "not in" — rewind so the downstream error reports - // the trailing-ident properly. - self.pos = saved_pos; - } - } - - let op = match self.peek() { - Some(Tok::Eq) => Some(CompareOp::Eq), - Some(Tok::NotEq) => Some(CompareOp::NotEq), - Some(Tok::Gt) => Some(CompareOp::Gt), - Some(Tok::GtEq) => Some(CompareOp::GtEq), - Some(Tok::Lt) => Some(CompareOp::Lt), - Some(Tok::LtEq) => Some(CompareOp::LtEq), - Some(Tok::Contains) => Some(CompareOp::Contains), - _ => None, - }; - - let Some(op) = op else { - // Bare identifier. - return Ok(Expression::Condition(Condition::IsTrue { key })); - }; - self.bump(); - - let value = match self.bump() { - Some(Tok::StringLit(s)) => Literal::String(s), - Some(Tok::IntLit(i)) => Literal::Int(i), - Some(Tok::FloatLit(f)) => Literal::Float(f), - Some(Tok::BoolLit(b)) => Literal::Bool(b), - Some(Tok::Ident(_)) => { - return Err(self.err( - "RHS-as-identifier on comparison operators not supported — \ - for set membership use `value_key in set_key`", - )); - }, - other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))), - }; - - Ok(Expression::Condition(Condition::Comparison { - key, - op, - value, - })) - } - - fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result { - let set_key = match self.bump() { - Some(Tok::Ident(s)) => s, - other => { - return Err(self.err(&format!( - "expected set-attribute identifier after `{}in`, got {:?}", - if negate { "not " } else { "" }, - other, - ))) - }, - }; - Ok(Expression::Condition(Condition::InSet { - value_key, - set_key, - negate, - })) - } -} - -/// Parse a predicate string into the IR. Public for tests. -pub fn parse_predicate(src: &str) -> Result { - PredParser::parse(src.trim()) -} - -/// Parse a single rule line into a `Rule`. -/// -/// Accepted forms: -/// 1. `"require(...)"` → rule-level shorthand, desugars to -/// `when: do: deny` -/// 2. `": "` → Rule { condition, action } -/// 3. `""` → Rule { condition, action: Deny } (default) -/// 4. `""` (action only) → treated as form 3 (always-true predicate) -/// -/// **Step kinds** (`plugin(...)`, `taint(...)`, `cedar:`, `opa(...)` etc.) -/// are handled by `parse_step`, not here. This function specifically parses -/// predicate-and-action rules; callers that don't know which they have -/// should use `parse_step` instead. -pub fn parse_rule(line: &str, source: &str) -> Result { - let trimmed = line.trim(); - - // require(...) shorthand — special-cased because it desugars to a - // negated predicate + Deny action, and the spec grammar puts it - // as a top-level rule alternative, not a sub-predicate. - if is_require_call(trimmed) { - let condition = parse_require_rule(trimmed)?; - return Ok(Rule::single( - condition, - Effect::Deny { - reason: None, - code: None, - }, - source, - )); - } - - // Step kinds shouldn't end up here. If they do, the caller used the - // wrong entry point — point them at parse_step. - if let Some(kind) = detect_step_kind(trimmed) { - return Err(ParseError::UnsupportedStep { - rule: trimmed.to_string(), - kind: format!("{} (use parse_step for step kinds)", kind), - }); - } - - let (predicate_str, effects) = match split_predicate_action(trimmed) { - Some((p, a)) => (p, parse_action(a, trimmed)?), - None => { - // No `:` — bare action (unconditional) or bare predicate (default deny). - if let Some(effects) = try_bare_action(trimmed) { - return Ok(Rule { - condition: Expression::Always, - effects, - source: source.to_string(), - }); - } - // Unconditional `deny('reason')` / `deny('reason', 'code')` — - // the call form of a bare deny. Lets reaction lists - // (`on_deny: [...]` / `on_allow: [...]`) and standalone rule - // lines attach a reason/code without a guard predicate. A - // malformed `deny(...)` surfaces its own error here rather - // than being misread as a predicate downstream. - if let Some(deny) = try_parse_deny_call(trimmed, trimmed)? { - return Ok(Rule { - condition: Expression::Always, - effects: vec![deny], - source: source.to_string(), - }); - } - // Default: bare predicate denies. - ( - trimmed, - vec![Effect::Deny { - reason: None, - code: None, - }], - ) - }, - }; - - let condition = parse_predicate(predicate_str).map_err(|e| ParseError::Rule { - rule: trimmed.to_string(), - msg: format!("{}", e), - })?; - - Ok(Rule { - condition, - effects, - source: source.to_string(), - }) -} - -fn is_require_call(s: &str) -> bool { - s.trim_start().starts_with("require(") -} - -/// Parse `require(a)` / `require(a, b, ...)` / `require(a | b | ...)` and -/// return the desugared "when" expression: -/// -/// require(X) → IsFalse(X) -/// require(X, Y, ...) → Or([IsFalse(X), IsFalse(Y), ...]) (deny if any falsy) -/// require(X | Y | ...) → And([IsFalse(X), IsFalse(Y), ...]) (deny if all falsy) -/// -/// Caller wraps with `Effect::Deny`. -fn parse_require_rule(line: &str) -> Result { - let toks = Lexer::new(line).tokenize_all()?; - let mut iter = toks.into_iter().peekable(); - - let bad = |msg: &str| ParseError::Rule { - rule: line.to_string(), - msg: msg.to_string(), - }; - - match iter.next() { - Some(Tok::Require) => {}, - _ => return Err(bad("expected `require`")), - } - match iter.next() { - Some(Tok::LParen) => {}, - _ => return Err(bad("expected `(` after `require`")), - } - - let mut keys = Vec::new(); - let mut sep: Option = None; - - match iter.next() { - Some(Tok::Ident(s)) => keys.push(s), - _ => return Err(bad("expected identifier inside `require(...)`")), - } - - loop { - match iter.next() { - Some(Tok::RParen) => break, - Some(t @ Tok::Comma) | Some(t @ Tok::Or) => { - match &sep { - None => sep = Some(t), - Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {}, - _ => { - return Err(bad( - "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other", - )) - }, - } - match iter.next() { - Some(Tok::Ident(s)) => keys.push(s), - _ => return Err(bad("expected identifier after `,` or `|` in require(...)")), - } - }, - Some(other) => { - return Err(bad(&format!( - "expected `,`, `|`, or `)` in require(...), got {:?}", - other, - ))) - }, - None => return Err(bad("unexpected end of require(...) — missing `)`")), - } - } - - if iter.peek().is_some() { - return Err(bad( - "trailing tokens after `require(...)` — require is a complete rule", - )); - } - - let falses: Vec = keys - .into_iter() - .map(|k| Expression::Condition(Condition::IsFalse { key: k })) - .collect(); - if falses.len() == 1 { - return Ok(falses - .into_iter() - .next() - .expect("falses.len() == 1 was checked above")); - } - Ok(match sep { - Some(Tok::Or) => Expression::And(falses), // require(X | Y) → !X & !Y - _ => Expression::Or(falses), // require(X, Y) → !X | !Y - }) -} - -/// Detect `taint(...)` / `plugin(...)` / `run(...)` / `cedar:` / `opa(` / `authzen(` / `nemo(` / `cel:`. -fn detect_step_kind(s: &str) -> Option<&'static str> { - let s = s.trim_start(); - for prefix in [ - "taint(", - "plugin(", - "run(", - "cedar:", - "opa(", - "authzen(", - "nemo(", - "cel:", - "sequential:", - "parallel:", - ] { - if s.starts_with(prefix) { - return Some(prefix.trim_end_matches('(').trim_end_matches(':')); - } - } - None -} - -/// Split on the *last* unescaped `:` that's outside quotes and parens — this -/// is the predicate/action separator. The DSL doesn't escape colons, and `:` -/// doesn't appear in our predicate grammar, but quotes and parens can contain -/// arbitrary text. -fn split_predicate_action(s: &str) -> Option<(&str, &str)> { - let bytes = s.as_bytes(); - let mut depth: i32 = 0; - let mut in_quote: Option = None; - let mut last_colon: Option = None; - for (i, &b) in bytes.iter().enumerate() { - match (in_quote, b) { - (Some(q), c) if c == q => in_quote = None, - (Some(_), _) => {}, - (None, b'"') | (None, b'\'') => in_quote = Some(b), - (None, b'(') => depth += 1, - (None, b')') => depth -= 1, - (None, b':') if depth == 0 => last_colon = Some(i), - _ => {}, - } - } - last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim())) -} - -/// Parse the *right* side of a shorthand `predicate: action` rule into a -/// single-element effects vec. Recognized forms (the `code` -/// extension we added): -/// -/// * `deny` → `vec![Effect::Deny { reason: None, code: None }]` -/// * `deny('reason')` → `vec![Effect::Deny { reason: Some, code: None }]` -/// * `deny('reason', 'code')` → `vec![Effect::Deny { reason: Some, code: Some }]` -/// * `allow` → `vec![Effect::Allow]` -/// -/// Anything else (plugin/delegate/taint) goes through `parse_step`, not -/// here — those are sibling Steps in v0. Multi-effect `do:` lists use a -/// separate parsing path that produces `Vec` directly. -fn parse_action(s: &str, rule: &str) -> Result, ParseError> { - if let Some(effect) = try_bare_action(s) { - return Ok(effect); - } - if let Some(deny) = try_parse_deny_call(s.trim(), rule)? { - return Ok(vec![deny]); - } - Err(ParseError::Rule { - rule: rule.to_string(), - msg: format!( - "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`", - s.trim() - ), - }) -} - -fn try_bare_action(s: &str) -> Option> { - match s.trim() { - "deny" => Some(vec![Effect::Deny { - reason: None, - code: None, - }]), - "allow" => Some(vec![Effect::Allow]), - _ => None, - } -} - -/// Parse `deny('reason')` or `deny('reason', 'code')`. Returns -/// `Ok(None)` when `s` doesn't start with `deny(` so the caller can -/// fall through to other action handlers. -fn try_parse_deny_call(s: &str, rule: &str) -> Result, ParseError> { - if !s.starts_with("deny(") { - return Ok(None); - } - let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule { - rule: rule.to_string(), - msg: "malformed `deny(...)`".into(), - })?; - // Two positional args max. Spec precedent: `deny('reason')` (1 arg); - // Extension: `deny('reason', 'code')` (2 args). Both quoted. - let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule { - rule: rule.to_string(), - msg: format!("deny(...): {}", e), - })?; - let mut iter = parts.into_iter(); - let reason = match iter.next() { - Some(p) => Some(strip_string_literal(p.trim(), rule)?), - None => None, - }; - let code = match iter.next() { - Some(p) => Some(strip_string_literal(p.trim(), rule)?), - None => None, - }; - if iter.next().is_some() { - return Err(ParseError::Rule { - rule: rule.to_string(), - msg: "deny(...) takes at most two args: deny('reason', 'code')".into(), - }); - } - Ok(Some(Effect::Deny { reason, code })) -} - -/// Strip surrounding single or double quotes from a literal. The DSL -/// uses single quotes (`'reason'`) per the spec examples, but accept -/// double quotes too so YAML escaping is forgiving. -fn strip_string_literal(s: &str, rule: &str) -> Result { - let s = s.trim(); - if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) - || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) - { - Ok(s[1..s.len() - 1].to_string()) - } else { - Err(ParseError::Rule { - rule: rule.to_string(), - msg: format!("expected a quoted string, got `{}`", s), - }) - } -} - -/// Parse a single YAML entry from a `pre_invocation` / `post_invocation` list. -/// -/// Two YAML shapes: -/// - **String entry** — a rule line, taint effect, or plugin call. -/// - `"require(authenticated)"` → `Step::Rule` -/// - `"delegation.depth > 2: deny"` → `Step::Rule` -/// - `"plugin(rate_limiter)"` → `Step::Plugin` (`"run(rate_limiter)"` is an alias) -/// - `"taint(PII, session)"` → `Step::Taint` -/// - **Map entry** (single-key map) — PDP call with optional reactions. -/// - `cedar: { action: read, resource: e, on_deny: [...] }` → `Step::Pdp` -/// - `opa("path"): { on_deny: [...] }` → `Step::Pdp` -pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result { - match value { - serde_yaml::Value::String(s) => parse_step_string(s, source), - serde_yaml::Value::Mapping(m) => parse_step_map(m, source), - other => Err(ParseError::Rule { - rule: format!("{:?}", other), - msg: "step must be a string or a single-key map".into(), - }), - } -} - -fn parse_step_string(line: &str, source: &str) -> Result { - let trimmed = line.trim(); - - // taint(...) — emit as Step::Taint, reusing the pipeline parser's logic - // so the shape stays consistent with field-level taint. - if trimmed.starts_with("taint(") { - let inside = extract_call_args(trimmed, "taint").ok_or_else(|| ParseError::Rule { - rule: trimmed.to_string(), - msg: "malformed `taint(...)`".into(), - })?; - let taint_stage = parse_taint(&inside, trimmed)?; - // parse_taint produces Stage::Taint; lift to Step::Taint. - if let Stage::Taint { label, scopes } = taint_stage { - return Ok(Step::Taint { label, scopes }); - } - unreachable!("parse_taint always returns Stage::Taint"); - } - - // plugin(name) / run(name) — invoke a named plugin. `run` is an - // alias for `plugin`; both emit Step::Plugin. - let plugin_verb = if trimmed.starts_with("plugin(") { - Some("plugin") - } else if trimmed.starts_with("run(") { - Some("run") - } else { - None - }; - if let Some(verb) = plugin_verb { - let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule { - rule: trimmed.to_string(), - msg: format!("malformed `{verb}(...)`"), - })?; - let name = inside.trim(); - if name.is_empty() { - return Err(ParseError::Rule { - rule: trimmed.to_string(), - msg: format!("`{verb}(...)`: plugin name must not be empty"), - }); - } - return Ok(Step::Plugin { - name: name.to_string(), - }); - } - - // delegate(name, key: value, key: [a, b], ...) — emit as Step::Delegate. - // Compact alternative to the map form (`- delegate: { plugin: ..., ... }`). - // First positional arg is the plugin name; subsequent `key: value` - // pairs become per-call config overrides (or `on_error` if the key - // is reserved). Use the map form for nested configs the kwarg - // parser doesn't handle. - if trimmed.starts_with("delegate(") { - let inside = extract_call_args(trimmed, "delegate").ok_or_else(|| ParseError::Rule { - rule: trimmed.to_string(), - msg: "malformed `delegate(...)`".into(), - })?; - let parsed = parse_delegate_call_args(&inside, source)?; - return Ok(Step::Delegate(DelegateStep { - plugin_name: parsed.plugin_name, - config_override: parsed.config_override, - on_error: parsed.on_error, - source: source.to_string(), - })); - } - - // Elicitation sugar verbs — each desugars to `Step::Elicit` with a - // fixed `ElicitKind`. All-kwarg form (`from:`, `channel:`, …), same - // `key: value` shape as `delegate(...)`. Verbs are matched with the - // trailing `(` so `require_approval` / `require_attestation` / - // `require_review` / `require_step_up` don't collide on the - // `require_` prefix. - for (verb, kind) in ELICIT_VERBS { - if trimmed.starts_with(&format!("{verb}(")) { - let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule { - rule: trimmed.to_string(), - msg: format!("malformed `{verb}(...)`"), - })?; - let parsed = parse_elicit_call_args(verb, &inside, source)?; - return Ok(Step::Elicit(ElicitStep { - kind: *kind, - plugin_name: parsed.plugin_name, - channel: parsed.channel, - from: parsed.from, - purpose: parsed.purpose, - scope: parsed.scope, - timeout: parsed.timeout, - config_override: parsed.config_override, - on_error: parsed.on_error, - source: source.to_string(), - })); - } - } - - // Otherwise fall through to the rule parser — predicate-and-action. - let rule = parse_rule(trimmed, source)?; - Ok(Step::Rule(rule)) -} - -/// Intermediate shape produced by [`parse_delegate_call_args`]. The -/// string-form parser fills this; the caller wraps into `Step::Delegate` -/// with the source path it has in scope. -struct ParsedDelegateCall { - plugin_name: String, - config_override: Option, - on_error: Option, -} - -/// Parse the inside-parens of `delegate(name, key: value, key: [a, b], ...)`. -/// -/// Grammar (informal): -/// ```text -/// delegate_args := plugin_name [, kwarg [, kwarg]*] -/// plugin_name := bare_ident_or_string -/// kwarg := key ":" value -/// value := scalar | "[" value (, value)* "]" -/// scalar := bare_word | number | "true" | "false" | quoted_string -/// ``` -/// -/// Reserved keys consumed before going into `config_override`: -/// - `on_error` — pulled out as `DelegateStep.on_error` -/// -/// Everything else lands in `config_override` as a yaml mapping. Use -/// the map form (`- delegate: { plugin: ..., config: { ... }, ... }`) -/// for nested config shapes the flat kwarg parser doesn't handle. -fn parse_delegate_call_args(inside: &str, source: &str) -> Result { - let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule { - rule: format!("delegate({inside})"), - msg: format!("{source}: {msg}"), - })?; - let mut parts_iter = parts.into_iter(); - - let plugin_name = parts_iter - .next() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| ParseError::Rule { - rule: format!("delegate({inside})"), - msg: format!( - "{source}: `delegate(...)` requires a plugin name as the first \ - positional argument" - ), - })?; - // Strip wrapping quotes if the operator wrote `delegate("workday-oauth", ...)`. - let plugin_name = strip_wrapping_quotes(&plugin_name).to_string(); - if plugin_name.is_empty() { - return Err(ParseError::Rule { - rule: format!("delegate({inside})"), - msg: format!("{source}: `delegate(...)` plugin name cannot be empty"), - }); - } - - let mut on_error: Option = None; - let mut config_map = serde_yaml::Mapping::new(); - - for raw_kwarg in parts_iter { - let kwarg = raw_kwarg.trim(); - if kwarg.is_empty() { - continue; - } - let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!( - "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \ - (use the map form for richer config)" - ), - })?; - let key = key.trim(); - let value_str = value_str.trim(); - if key.is_empty() { - return Err(ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: `delegate(...)` kwarg has empty key"), - }); - } - if key == "on_error" { - let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: on_error: {msg}"), - })?; - on_error = Some( - val.as_str() - .ok_or_else(|| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: `on_error` must be a string"), - })? - .to_string(), - ); - continue; - } - // Reject `plugin:` as a kwarg — the plugin name is the positional - // first argument; allowing both would be ambiguous. - if key == "plugin" { - return Err(ParseError::Rule { - rule: kwarg.to_string(), - msg: format!( - "{source}: `plugin` is set as the first positional argument \ - of `delegate(...)`; don't pass it as a kwarg too" - ), - }); - } - let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: `{key}`: {msg}"), - })?; - config_map.insert(serde_yaml::Value::String(key.to_string()), value); - } - - let config_override = if config_map.is_empty() { - None - } else { - Some(serde_yaml::Value::Mapping(config_map)) - }; - - Ok(ParsedDelegateCall { - plugin_name, - config_override, - on_error, - }) -} - -/// Sugar verb → [`ElicitKind`] table. Each verb desugars to the same -/// `Step::Elicit` node with the kind fixed. -const ELICIT_VERBS: &[(&str, ElicitKind)] = &[ - ("require_approval", ElicitKind::Approval), - ("confirm", ElicitKind::Confirm), - ("require_step_up", ElicitKind::StepUp), - ("require_attestation", ElicitKind::Attestation), - ("request_info", ElicitKind::Info), - ("require_review", ElicitKind::Review), -]; - -/// Intermediate shape produced by [`parse_elicit_call_args`]. The -/// caller fixes `kind` (from the verb) and `source`, then wraps into -/// `Step::Elicit`. -struct ParsedElicitCall { - plugin_name: String, - from: String, - channel: Option, - purpose: Option, - scope: Option, - timeout: Option, - on_error: Option, - config_override: Option, -} - -/// Parse the inside-parens of an elicitation verb, -/// `verb(plugin_name, from: ..., scope: ..., purpose: ..., timeout: ...)`. -/// -/// Shape mirrors `delegate(...)`: the **first positional argument is the -/// `ElicitationHandler` plugin name** (the routing key, resolved -/// `name → entry`). `from` is a required kwarg (the approver, CIBA -/// `login_hint`). `channel` is an OPTIONAL audit label — not a routing -/// key. Recognized keys map to `ElicitStep` fields; `prompt` is an alias -/// for `purpose` (the elicitation-hook doc uses `prompt` for -/// `confirm`/`require_attestation`, `purpose` for `require_approval` — -/// both are the human-readable message). Everything else lands in -/// `config_override` (e.g. `details_link`) for the plugin. -fn parse_elicit_call_args( - verb: &str, - inside: &str, - source: &str, -) -> Result { - let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule { - rule: format!("{verb}({inside})"), - msg: format!("{source}: {msg}"), - })?; - let mut parts_iter = parts.into_iter(); - - // First positional argument: the plugin name (same as delegate()). - let plugin_name = parts_iter - .next() - .map(|s| strip_wrapping_quotes(s.trim()).to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| ParseError::Rule { - rule: format!("{verb}({inside})"), - msg: format!( - "{source}: `{verb}(...)` requires an ElicitationHandler plugin name \ - as the first positional argument" - ), - })?; - - let mut from: Option = None; - let mut channel: Option = None; - let mut purpose: Option = None; - let mut scope: Option = None; - let mut timeout: Option = None; - let mut on_error: Option = None; - let mut config_map = serde_yaml::Mapping::new(); - - // Coerce a parsed value to a string, erroring if it isn't one. - let as_string = |value: serde_yaml::Value, key: &str| -> Result { - match value { - serde_yaml::Value::String(s) => Ok(s), - other => Err(ParseError::Rule { - rule: format!("{verb}(...)"), - msg: format!("{source}: `{key}` must be a string, got {other:?}"), - }), - } - }; - - for raw_kwarg in parts_iter { - let kwarg = raw_kwarg.trim(); - if kwarg.is_empty() { - continue; - } - let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!( - "{source}: `{verb}(...)` argument `{kwarg}` must be `key: value` \ - (the plugin name is the first positional argument)" - ), - })?; - let key = key.trim(); - let value_str = value_str.trim(); - if key.is_empty() { - return Err(ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: `{verb}(...)` argument has empty key"), - }); - } - let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: `{key}`: {msg}"), - })?; - match key { - "from" => from = Some(as_string(value, "from")?), - "channel" => channel = Some(as_string(value, "channel")?), - "scope" => scope = Some(as_string(value, "scope")?), - "purpose" | "prompt" => purpose = Some(as_string(value, key)?), - "timeout" => timeout = Some(as_string(value, "timeout")?), - "on_error" => on_error = Some(as_string(value, "on_error")?), - // Reject `plugin:` as a kwarg — it's the positional arg. - "plugin" => { - return Err(ParseError::Rule { - rule: kwarg.to_string(), - msg: format!( - "{source}: the plugin name is the first positional argument \ - of `{verb}(...)`; don't pass it as a kwarg too" - ), - }); - }, - _ => { - config_map.insert(serde_yaml::Value::String(key.to_string()), value); - }, - } - } - - let from = from.ok_or_else(|| ParseError::Rule { - rule: format!("{verb}({inside})"), - msg: format!("{source}: `{verb}(...)` requires `from` (the approver)"), - })?; - - let config_override = if config_map.is_empty() { - None - } else { - Some(serde_yaml::Value::Mapping(config_map)) - }; - - Ok(ParsedElicitCall { - plugin_name, - from, - channel, - purpose, - scope, - timeout, - on_error, - config_override, - }) -} - -/// Split a `key: value, key: value` string on TOP-LEVEL commas only — -/// commas inside `[...]` or quoted strings are preserved as part of -/// the surrounding value. Returns the comma-separated pieces (trimmed -/// at boundaries; whitespace inside values preserved). -/// -/// Errors on unmatched brackets / unterminated quotes — those produce -/// confusing downstream errors otherwise. -fn split_top_level_commas(input: &str) -> Result, String> { - let mut parts = Vec::new(); - let mut current = String::new(); - let mut bracket_depth: usize = 0; - let mut quote: Option = None; - let mut escape = false; - - for ch in input.chars() { - if escape { - current.push(ch); - escape = false; - continue; - } - if let Some(q) = quote { - current.push(ch); - if ch == '\\' { - escape = true; - } else if ch == q { - quote = None; - } - continue; - } - match ch { - '"' | '\'' => { - quote = Some(ch); - current.push(ch); - }, - '[' | '(' | '{' => { - bracket_depth += 1; - current.push(ch); - }, - ']' | ')' | '}' => { - bracket_depth = bracket_depth - .checked_sub(1) - .ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?; - current.push(ch); - }, - ',' if bracket_depth == 0 => { - parts.push(std::mem::take(&mut current)); - }, - _ => current.push(ch), - } - } - if quote.is_some() { - return Err("unterminated quoted string in delegate(...) args".to_string()); - } - if bracket_depth != 0 { - return Err("unbalanced brackets in delegate(...) args".to_string()); - } - parts.push(current); - Ok(parts) -} - -/// Parse a single value from the function-call form: a scalar -/// (string / number / bool) or a list literal `[a, b, c]`. Use the -/// map form for anything more complex. -fn parse_delegate_value(s: &str) -> Result { - let trimmed = s.trim(); - if trimmed.is_empty() { - return Err("empty value".to_string()); - } - // List literal — recursive scalar parse on each element. - if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { - let items = split_top_level_commas(stripped)?; - let mut out = Vec::with_capacity(items.len()); - for item in items { - let item = item.trim(); - if item.is_empty() { - continue; - } - out.push(parse_delegate_value(item)?); - } - return Ok(serde_yaml::Value::Sequence(out)); - } - // Quoted string — strip the surrounding quotes. - if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2) - || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2) - { - return Ok(serde_yaml::Value::String( - trimmed[1..trimmed.len() - 1].to_string(), - )); - } - // Bool literals. - if trimmed == "true" { - return Ok(serde_yaml::Value::Bool(true)); - } - if trimmed == "false" { - return Ok(serde_yaml::Value::Bool(false)); - } - // Numeric literals — integer first, then float. - if let Ok(n) = trimmed.parse::() { - return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n))); - } - if let Ok(f) = trimmed.parse::() { - return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f))); - } - // Fallback: treat as bare string (e.g. `target: workday-api` → - // value is `workday-api`). Same convention as YAML scalars. - Ok(serde_yaml::Value::String(trimmed.to_string())) -} - -/// Strip a single pair of wrapping `"`/`'` if present. No-op on -/// unquoted input. Used for the positional plugin name where the -/// operator may have quoted to escape a hyphen or similar (`delegate("workday-oauth")`). -fn strip_wrapping_quotes(s: &str) -> &str { - let bytes = s.as_bytes(); - if bytes.len() >= 2 { - let first = bytes[0]; - let last = bytes[bytes.len() - 1]; - if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { - return &s[1..s.len() - 1]; - } - } - s -} - -fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result { - // Canonical structured rule: `- when: X\n do: Y`. - // Detected by the presence of *both* `when` and `do` keys — order - // doesn't matter, and the map can carry extra keys for future - // extensions (e.g. `id:` for rule identifiers). - if has_key(m, "when") && has_key(m, "do") { - return parse_when_do_rule(m, source); - } - - if m.len() != 1 { - return Err(ParseError::Rule { - rule: format!("{:?}", m), - msg: "step map must have exactly one key (PDP call signature, \ - `when:`/`do:`, or a `predicate: [effects...]` shorthand)" - .into(), - }); - } - let (key_val, body_val) = m - .iter() - .next() - .expect("m.len() == 1 was checked above, so an entry must exist"); - let key = key_val.as_str().ok_or_else(|| ParseError::Rule { - rule: format!("{:?}", key_val), - msg: "PDP step key must be a string".into(), - })?; - - // Shorthand multi-effect map: `- "predicate": [list]` (multi-effect - // from one predicate). Detected by a single-key map - // whose value is a YAML sequence. Single-effect map shorthand - // (`- "predicate": deny`) still goes through `parse_step_string` - // via the colon-split, NOT here — by the time we land in this - // function, single-string values have already been resolved by - // the caller's `parse_step` dispatch. - if let serde_yaml::Value::Sequence(items) = body_val { - // Skip PDP keys — `cedar:` / `opa:` etc. have list bodies for - // `on_deny:` / `on_allow:` and need the existing handling. - // Also skip `sequential:` / `parallel:` orchestration keys - // since they take a list body and would otherwise be parsed - // as predicates. The shorthand recognises only predicate- - // shaped keys. - let trimmed = key.trim(); - if trimmed != "delegate" - && trimmed != "sequential" - && trimmed != "parallel" - && !is_known_pdp_dialect(trimmed) - { - return parse_shorthand_multi_effect(trimmed, items, source); - } - } - - // `delegate:` is a special non-PDP step shape — branch before the - // dialect logic. See `parse_delegate_step` for the expected body. - if key.trim() == "delegate" { - return parse_delegate_step(body_val, source); - } - - // `restrict:` — the backend candidate constraint (accumulating - // effect, `Taint` family). Body is a map of typed fields + a - // `custom` label map; branch before the PDP-dialect logic since - // `restrict` is not a PDP call. - if key.trim() == "restrict" { - let spec = parse_restrict_spec(body_val, source)?; - return Ok(Step::Restrict { spec }); - } - - // Top-level `sequential:` / `parallel:` orchestration — - // wrap the resulting Effect into an unconditional Rule so the - // top-level Vec stays uniform. - match key.trim() { - "sequential" => { - let effect = parse_sequential_effect(body_val, source)?; - return Ok(Step::Rule(Rule { - condition: Expression::Always, - effects: vec![effect], - source: source.to_string(), - })); - }, - "parallel" => { - let effect = parse_parallel_effect(body_val, source)?; - return Ok(Step::Rule(Rule { - condition: Expression::Always, - effects: vec![effect], - source: source.to_string(), - })); - }, - _ => {}, - } - - // Split the key into "dialect" + optional "(args)" portion. - let (dialect_str, paren_args) = if let Some(open) = key.find('(') { - let close = key.rfind(')').ok_or_else(|| ParseError::Rule { - rule: key.to_string(), - msg: "missing `)` in PDP call signature".into(), - })?; - let inside = key[open + 1..close].trim().to_string(); - (key[..open].trim(), Some(inside)) - } else { - (key.trim(), None) - }; - - let dialect = PdpDialect::from_key(dialect_str); - - // Extract args + on_deny/on_allow. - // Cedar: body map carries args fields directly + on_deny/on_allow. - // Others: paren_args carries the call signature; body map is reactions only. - let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { - rule: format!("{:?}", body_val), - msg: format!( - "`{}:` body must be a map (with on_deny / on_allow / args)", - key - ), - })?; - - let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?; - - Ok(Step::Pdp { - call: PdpCall { dialect, args }, - on_deny, - on_allow, - }) -} - -/// Lookup helper — `serde_yaml::Mapping::contains_key` only matches when -/// the search key is a `Value`, so we wrap the string conversion. -fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool { - m.contains_key(serde_yaml::Value::String(key.to_string())) -} - -/// Whether a top-level map key is a recognized PDP dialect. Used by -/// the shorthand-list detector to avoid mis-parsing a `cedar: [...]` -/// reaction list as a predicate-with-effects map. -fn is_known_pdp_dialect(key: &str) -> bool { - let base = key.find('(').map(|i| &key[..i]).unwrap_or(key); - matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel") -} - -/// Parse the canonical `- when: X` `do: Y` rule form. `Y` -/// may be a single effect string (`do: deny`) or a list of effect -/// entries (`do: [plugin(audit), taint(X), deny('msg')]`). Map-form -/// effects (like a nested `delegate:` block) are allowed inside `do:` -/// via the same dispatch as top-level steps. -fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result { - // Validate keys — surface a useful error if there's stray content - // beyond `when:` / `do:` (e.g. typo'd `whens:`). `id:` is reserved - // for a future rule-identifier extension; tolerate it as a - // pass-through for now. - for (k, _) in m.iter() { - let key = k.as_str().unwrap_or(""); - if !matches!(key, "when" | "do" | "id") { - return Err(ParseError::Rule { - rule: format!("{:?}", m), - msg: format!( - "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)", - key - ), - }); - } - } - - let when_val = m - .get(serde_yaml::Value::String("when".into())) - .expect("has_key verified above"); - let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule { - rule: format!("{:?}", when_val), - msg: "`when:` must be a predicate string".into(), - })?; - let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule { - rule: format!("when: {}", predicate), - msg: format!("{}", e), - })?; - - let do_val = m - .get(serde_yaml::Value::String("do".into())) - .expect("has_key verified above"); - let effects = parse_do_body(do_val, source)?; - if effects.is_empty() { - return Err(ParseError::Rule { - rule: format!("{:?}", m), - msg: "`do:` produced no effects".into(), - }); - } - - Ok(Step::Rule(Rule { - condition, - effects, - source: source.to_string(), - })) -} - -/// Parse the shorthand multi-effect map form: `- "predicate": [list]`. -/// Equivalent to the canonical -/// `when: predicate` `do: [list]` shape, just terser. -fn parse_shorthand_multi_effect( - predicate: &str, - effect_list: &[serde_yaml::Value], - source: &str, -) -> Result { - let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule { - rule: predicate.to_string(), - msg: format!("{}", e), - })?; - - let mut effects = Vec::with_capacity(effect_list.len()); - for item in effect_list { - effects.push(parse_effect_value(item, source)?); - } - if effects.is_empty() { - return Err(ParseError::Rule { - rule: predicate.to_string(), - msg: "shorthand multi-effect map produced no effects".into(), - }); - } - Ok(Step::Rule(Rule { - condition, - effects, - source: source.to_string(), - })) -} - -/// Parse a `do:` body — single effect string, list of effects, or a -/// single map-shaped effect (`do: { parallel: [...] }`, -/// `do: { delegate: {...} }`, etc.). -fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result, ParseError> { - match val { - serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]), - serde_yaml::Value::Sequence(items) => items - .iter() - .map(|item| parse_effect_value(item, source)) - .collect(), - serde_yaml::Value::Mapping(_) => { - // Single map-form effect — delegate, sequential, parallel. - // Route through parse_effect_value which dispatches by key. - Ok(vec![parse_effect_value(val, source)?]) - }, - other => Err(ParseError::Rule { - rule: format!("{:?}", other), - msg: "`do:` value must be a string, a list of effects, or an effect map".into(), - }), - } -} - -/// Parse one effect entry from a YAML value — string form or map form -/// (the latter for `delegate:` configs nested inside `do:`, -/// `sequential:`, and `parallel:`). -fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result { - match val { - serde_yaml::Value::String(s) => parse_effect_string(s, source), - serde_yaml::Value::Mapping(m) => { - // `sequential:` / `parallel:` map forms — a single-key - // map whose key is `sequential` / `parallel` and whose - // value is a list of effects. - if m.len() == 1 { - let (k, v) = m - .iter() - .next() - .expect("m.len() == 1 was checked above, so an entry must exist"); - if let Some(key_str) = k.as_str() { - match key_str.trim() { - "sequential" => return parse_sequential_effect(v, source), - "parallel" => return parse_parallel_effect(v, source), - "restrict" => return parse_restrict_effect(v, source), - _ => {}, - } - } - } - // Otherwise reuse the existing step-map parser for - // `delegate:`, `cedar:` etc. and collapse the Step. - let step = parse_step(val, source)?; - step_to_effect(step, source) - }, - other => Err(ParseError::Rule { - rule: format!("{:?}", other), - msg: "effect entry must be a string or a map".into(), - }), - } -} - -/// Parse a `sequential: [list]` effect value. The body MUST be a list -/// (a single effect would defeat the purpose of explicit grouping). -fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result { - let items = body.as_sequence().ok_or_else(|| ParseError::Rule { - rule: format!("{:?}", body), - msg: "`sequential:` body must be a list of effects".into(), - })?; - if items.is_empty() { - return Err(ParseError::Rule { - rule: format!("{:?}", body), - msg: "`sequential:` body is empty".into(), - }); - } - let mut effects = Vec::with_capacity(items.len()); - for item in items { - effects.push(parse_effect_value(item, source)?); - } - Ok(Effect::Sequential(effects)) -} - -/// Parse a `parallel: [list]` effect value. The body MUST be a list, -/// and the parsed Effect is validated for parallel-purity (rejects -/// `FieldOp` / `Delegate` nested anywhere underneath). -fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result { - let items = body.as_sequence().ok_or_else(|| ParseError::Rule { - rule: format!("{:?}", body), - msg: "`parallel:` body must be a list of effects".into(), - })?; - if items.is_empty() { - return Err(ParseError::Rule { - rule: format!("{:?}", body), - msg: "`parallel:` body is empty".into(), - }); - } - let mut effects = Vec::with_capacity(items.len()); - for item in items { - effects.push(parse_effect_value(item, source)?); - } - let parallel = Effect::Parallel(effects); - parallel - .validate_parallel_purity() - .map_err(|msg| ParseError::Rule { - rule: source.to_string(), - msg, - })?; - Ok(parallel) -} - -/// Parse a `restrict: { ... }` map into an `Effect::Restrict`. Thin -/// wrapper over [`parse_restrict_spec`] — the effect form (inside `do:` / -/// `sequential:` / `parallel:` / a PDP reaction) and the top-level step -/// form share the same body shape. -fn parse_restrict_effect(body: &serde_yaml::Value, source: &str) -> Result { - let spec = parse_restrict_spec(body, source)?; - Ok(Effect::Restrict { spec }) -} - -/// Parse a `restrict:` body map into a [`RestrictSpec`]. Every field is -/// optional, but an entirely empty `restrict:` is rejected — it would -/// constrain nothing, so it's an author error. Unknown keys are a hard -/// error: the constraint is a fixed contract we ask the host's router to -/// honor, and a typo'd field must never silently widen the eligible set. -/// -/// The string-set fields (`allow_models` / `deny_models` / `allow_regions` -/// / `allow_sites`) accept either a literal YAML list **or** a bare -/// scalar `data.*` reference resolved per request. -fn parse_restrict_spec( - body_val: &serde_yaml::Value, - source: &str, -) -> Result { - use crate::constraint::{OnEmpty, RestrictSpec}; - - let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { - rule: source.to_string(), - msg: "`restrict:` body must be a map of constraint fields (allow_models / \ - deny_models / allow_regions / allow_sites / max_cost_tier / custom / \ - on_empty)" - .to_string(), - })?; - - let mut spec = RestrictSpec::default(); - - for (k, v) in body.iter() { - let key = k.as_str().ok_or_else(|| ParseError::Rule { - rule: source.to_string(), - msg: "`restrict:` field keys must be strings".to_string(), - })?; - // Field-scoped error so authors see e.g. `restrict.allow_models: ...`. - let field_err = |msg: String| ParseError::Rule { - rule: source.to_string(), - msg: format!("`restrict.{}`: {}", key.trim(), msg), - }; - match key.trim() { - "allow_models" => { - spec.allow_models = Some(parse_string_set_spec(v).map_err(&field_err)?); - }, - "deny_models" => { - spec.deny_models = Some(parse_string_set_spec(v).map_err(&field_err)?); - }, - "allow_regions" => { - spec.allow_regions = Some(parse_string_set_spec(v).map_err(&field_err)?); - }, - "allow_sites" => { - spec.allow_sites = Some(parse_string_set_spec(v).map_err(&field_err)?); - }, - "max_cost_tier" => { - let tier = v - .as_str() - .ok_or_else(|| field_err("must be a string tier".to_string()))?; - if tier.trim().is_empty() { - return Err(field_err("tier must not be empty".to_string())); - } - spec.max_cost_tier = Some(tier.trim().to_string()); - }, - "custom" => { - spec.custom = parse_label_map(v).map_err(&field_err)?; - }, - "on_empty" => { - let s = v - .as_str() - .ok_or_else(|| field_err("must be `deny` or `fallback`".to_string()))?; - spec.on_empty = match s.trim() { - "deny" => OnEmpty::Deny, - "fallback" => OnEmpty::Fallback, - other => { - return Err(field_err(format!( - "unknown value `{}` (expected `deny` or `fallback`)", - other - ))) - }, - }; - }, - other => { - return Err(ParseError::Rule { - rule: source.to_string(), - msg: format!( - "unknown `restrict:` field `{}` (allowed: allow_models, \ - deny_models, allow_regions, allow_sites, max_cost_tier, \ - custom, on_empty)", - other - ), - }); - }, - } - } - - // `is_empty()` ignores `on_empty` (a bare `on_empty:` constrains - // nothing), so this also rejects `restrict: { on_empty: deny }`. - if spec.is_empty() { - return Err(ParseError::Rule { - rule: source.to_string(), - msg: "`restrict:` declares no constraint fields — it would restrict nothing; \ - remove it or add at least one of allow_models / deny_models / \ - allow_regions / allow_sites / max_cost_tier / custom" - .to_string(), - }); - } - - Ok(spec) -} - -/// Parse a `restrict` string-set field. A YAML **sequence** is a literal -/// set of strings; a bare **scalar string** is a `data.*` reference -/// resolved per request — e.g. -/// `allow_models: data.agents[subject.id].allowed_models`. -fn parse_string_set_spec( - v: &serde_yaml::Value, -) -> Result { - use crate::constraint::StringSetSpec; - match v { - serde_yaml::Value::Sequence(_) => Ok(StringSetSpec::Literal(parse_string_list(v)?)), - serde_yaml::Value::String(s) => { - let s = s.trim(); - if s.is_empty() { - return Err("reference path must not be empty".to_string()); - } - Ok(StringSetSpec::Ref(s.to_string())) - }, - _ => Err("must be a list of strings or a `data.*` reference string".to_string()), - } -} - -/// Parse a YAML value expected to be a non-empty list of non-empty -/// strings (the `allow_*` / `deny_*` constraint fields). Surrounding -/// whitespace is trimmed; interior characters (e.g. a `*` glob or a `/` -/// in a model id) are preserved. -fn parse_string_list(v: &serde_yaml::Value) -> Result, String> { - let seq = v - .as_sequence() - .ok_or_else(|| "must be a list of strings".to_string())?; - let mut out = Vec::with_capacity(seq.len()); - for item in seq { - let s = item - .as_str() - .ok_or_else(|| "list entries must be strings".to_string())?; - if s.trim().is_empty() { - return Err("list entries must not be empty".to_string()); - } - out.push(s.trim().to_string()); - } - if out.is_empty() { - return Err("list must not be empty".to_string()); - } - Ok(out) -} - -/// Parse a YAML value expected to be a flat map of `label: value` -/// pairs (the `custom` field). Scalar values (string / bool / number) -/// are coerced to their string form: `custom` is equality-matched -/// labels, not typed values. -fn parse_label_map( - v: &serde_yaml::Value, -) -> Result, String> { - let map = v - .as_mapping() - .ok_or_else(|| "must be a map of `label: value` pairs".to_string())?; - let mut out = std::collections::BTreeMap::new(); - for (k, val) in map { - let key = k - .as_str() - .ok_or_else(|| "label keys must be strings".to_string())?; - if key.trim().is_empty() { - return Err("label keys must not be empty".to_string()); - } - let value = scalar_to_string(val).ok_or_else(|| { - format!( - "label `{}` must be a scalar (string / bool / number)", - key.trim() - ) - })?; - out.insert(key.trim().to_string(), value); - } - if out.is_empty() { - return Err("`custom` map must not be empty".to_string()); - } - Ok(out) -} - -/// Coerce a scalar YAML value to its string form for a `custom` label. -/// Non-scalars (sequences, maps, null) return `None` — a label value -/// must be a single comparable token. -fn scalar_to_string(v: &serde_yaml::Value) -> Option { - match v { - serde_yaml::Value::String(s) => Some(s.clone()), - serde_yaml::Value::Bool(b) => Some(b.to_string()), - serde_yaml::Value::Number(n) => Some(n.to_string()), - _ => None, - } -} - -/// Parse one effect string. Reuses [`parse_step_string`] for forms -/// shared with top-level steps (`plugin(...)`, `taint(...)`, -/// `delegate(...)`, predicate-action rules), then collapses the -/// resulting Step into an Effect. -fn parse_effect_string(s: &str, source: &str) -> Result { - // Bare `allow` / `deny` / `deny('reason')` / `deny('reason', 'code')` - // are accepted directly — they map to control effects with no - // associated condition. Same parsing as the right-hand side of a - // shorthand `predicate: action` rule. - let trimmed = s.trim(); - if let Some(mut effects) = try_bare_action(trimmed) { - if effects.len() == 1 { - return Ok(effects.pop().expect("effects.len() == 1 was checked above")); - } - } - if let Some(effect) = try_parse_deny_call(trimmed, s)? { - return Ok(effect); - } - // Content effect — `result.salary | redact`, `args.ssn | mask(4)`, - // etc. Detected by a top-level `|` that splits a dotted path from - // a pipe chain. The pipe is at top level (depth 0); commas / - // parens inside the chain don't get confused. - if let Some(field_op) = try_parse_field_op(trimmed, s)? { - return Ok(field_op); - } - // Everything else (plugin/delegate/taint/rule) routes through the - // step parser; collapse the result. - let step = parse_step_string(s, source)?; - step_to_effect(step, source) -} - -/// Parse ` | [| ...]` into an `Effect::FieldOp`. -/// Returns `Ok(None)` when no top-level `|` is found so the caller can -/// fall through to other effect handlers. -fn try_parse_field_op(s: &str, rule: &str) -> Result, ParseError> { - let Some(pipe_idx) = find_top_level_pipe(s) else { - return Ok(None); - }; - let path = s[..pipe_idx].trim(); - let chain = s[pipe_idx + 1..].trim(); - if path.is_empty() || chain.is_empty() { - return Ok(None); - } - // The path must look like a dotted field reference. Anything else - // (e.g. `role.hr | role.security` — though that wouldn't get here - // because predicates don't appear in effect position) is a sign - // the author meant something other than a field op. - if !is_valid_field_path(path) { - return Ok(None); - } - let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { - rule: rule.to_string(), - msg: format!("field op `{}`: {}", path, e), - })?; - if pipeline.stages.is_empty() { - return Err(ParseError::Rule { - rule: rule.to_string(), - msg: format!("field op `{}` has no stages", path), - }); - } - Ok(Some(Effect::FieldOp { - path: path.to_string(), - stages: pipeline.stages, - })) -} - -/// Find the byte index of the first top-level `|` that isn't part of -/// `||` (logical-or inside a predicate). Depth-aware: skips `|` inside -/// `(...)` / `[...]` and inside single- or double-quoted strings. -fn find_top_level_pipe(s: &str) -> Option { - let bytes = s.as_bytes(); - let mut depth: i32 = 0; - let mut quote: Option = None; - let mut i = 0; - while i < bytes.len() { - let b = bytes[i]; - if let Some(q) = quote { - if b == b'\\' { - i += 2; - continue; - } - if b == q { - quote = None; - } - i += 1; - continue; - } - match b { - b'\'' | b'"' => quote = Some(b), - b'(' | b'[' => depth += 1, - b')' | b']' => depth -= 1, - b'|' if depth == 0 => { - // Skip `||` — never appears in effect strings today - // but defend against it anyway. - if bytes.get(i + 1) == Some(&b'|') { - i += 2; - continue; - } - return Some(i); - }, - _ => {}, - } - i += 1; - } - None -} - -/// A field path is a dotted identifier sequence rooted at `args.` or -/// `result.`. Reject anything else early so a stray `role.hr | …` in -/// effect position fails fast. -fn is_valid_field_path(s: &str) -> bool { - let Some(rest) = s - .strip_prefix("args.") - .or_else(|| s.strip_prefix("result.")) - else { - return false; - }; - !rest.is_empty() - && rest - .split('.') - .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_')) -} - -/// Collapse a `Step` produced by the legacy step parser into an -/// `Effect`. The legitimate inputs are `Plugin`, `Delegate`, `Taint`, -/// and `Rule` (when a control action like `deny`/`allow` was parsed). -/// Anything else (`Pdp`) is rejected — nested PDP calls inside `do:` -/// are out of scope. -/// Recursively map a top-level `Step` (as produced by `parse_step`) into -/// an `Effect`. Used at compile_apl_blocks — keeps `parse_step`'s -/// internal shape for the moment while the public IR collapses to Effect. -/// All five Step variants map cleanly: Rule → When, Pdp → Pdp (recursive -/// on reactions), Plugin/Delegate/Taint pass-through. -pub(crate) fn step_to_top_level_effect(step: Step) -> Result { - match step { - Step::Rule(rule) => Ok(Effect::When { - condition: rule.condition, - body: rule.effects, - source: rule.source, - }), - Step::Pdp { - call, - on_allow, - on_deny, - } => { - let on_allow = on_allow - .into_iter() - .map(step_to_top_level_effect) - .collect::, _>>()?; - let on_deny = on_deny - .into_iter() - .map(step_to_top_level_effect) - .collect::, _>>()?; - Ok(Effect::Pdp { - call, - on_allow, - on_deny, - }) - }, - Step::Plugin { name } => Ok(Effect::Plugin { name }), - Step::Delegate(d) => Ok(Effect::Delegate(d)), - Step::Elicit(e) => Ok(Effect::Elicit(e)), - Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), - Step::Restrict { spec } => Ok(Effect::Restrict { spec }), - } -} - -fn step_to_effect(step: Step, source: &str) -> Result { - match step { - Step::Plugin { name } => Ok(Effect::Plugin { name }), - Step::Delegate(d) => Ok(Effect::Delegate(d)), - Step::Elicit(e) => Ok(Effect::Elicit(e)), - Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), - Step::Restrict { spec } => Ok(Effect::Restrict { spec }), - Step::Rule(rule) => { - // Nested when/do inside a do: list isn't supported - // — only control effects (allow/deny) flatten cleanly. - if !matches!(rule.condition, Expression::Always) { - return Err(ParseError::Rule { - rule: source.to_string(), - msg: "conditional rules nested inside `do:` are not supported \ - (use a sibling `when:`/`do:` rule instead)" - .into(), - }); - } - if rule.effects.len() != 1 { - return Err(ParseError::Rule { - rule: source.to_string(), - msg: format!( - "unconditional rule inside `do:` must produce exactly one \ - effect, got {}", - rule.effects.len() - ), - }); - } - Ok(rule - .effects - .into_iter() - .next() - .expect("rule.effects.len() == 1 was checked above")) - }, - Step::Pdp { .. } => Err(ParseError::Rule { - rule: source.to_string(), - msg: "PDP calls inside `do:` are not supported (use a sibling \ - step instead)" - .into(), - }), - } -} - -/// Parse a `delegate:` step body into a `Step::Delegate`. Accepted -/// YAML shape: -/// -/// ```yaml -/// - delegate: -/// plugin: workday-oauth # required — TokenDelegateHook plugin name -/// config: # optional — per-call config override -/// target: workday-api -/// permissions: [read_compensation] -/// on_error: deny # optional — deny | continue (default deny) -/// ``` -/// -/// `config:` is opaque — the framework hands it to the named plugin -/// via the existing per-call config-override pathway. The plugin -/// owns the typed schema (target / audience / permissions / mode / -/// attenuation are conventions, not parser-enforced). -fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result { - let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { - rule: source.to_string(), - msg: "`delegate:` body must be a map with `plugin:` and optional \ - `config:` / `on_error:`" - .to_string(), - })?; - - let plugin = body - .get(serde_yaml::Value::String("plugin".to_string())) - .ok_or_else(|| ParseError::Rule { - rule: source.to_string(), - msg: "`delegate:` requires `plugin: ` referencing a \ - top-level plugin registered under `token.delegate`" - .to_string(), - })?; - let plugin_name = plugin - .as_str() - .ok_or_else(|| ParseError::Rule { - rule: source.to_string(), - msg: "`delegate.plugin` must be a string".to_string(), - })? - .to_string(); - if plugin_name.is_empty() { - return Err(ParseError::Rule { - rule: source.to_string(), - msg: "`delegate.plugin` cannot be empty".to_string(), - }); - } - - let config_override = body - .get(serde_yaml::Value::String("config".to_string())) - .cloned(); - - let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) { - Some(v) => Some( - v.as_str() - .ok_or_else(|| ParseError::Rule { - rule: source.to_string(), - msg: "`delegate.on_error` must be a string (e.g. `deny`, \ - `continue`)" - .to_string(), - })? - .to_string(), - ), - None => None, - }; - - Ok(Step::Delegate(DelegateStep { - plugin_name, - config_override, - on_error, - source: source.to_string(), - })) -} - -/// Split a PDP body into (args, on_deny, on_allow). -/// -/// If `paren_args` is `Some`, the call's args are the string inside the -/// parens (OPA-style) and the body map only carries reactions. If `None`, -/// the body map carries both args and reactions (Cedar-style); we strip -/// the reaction keys and treat what's left as args. -fn extract_pdp_body( - body: &serde_yaml::Mapping, - paren_args: Option<&str>, - source: &str, -) -> Result<(serde_yaml::Value, Vec, Vec), ParseError> { - let mut on_deny = Vec::new(); - let mut on_allow = Vec::new(); - let mut args_map = serde_yaml::Mapping::new(); - - for (k, v) in body { - match k.as_str() { - Some("on_deny") => { - on_deny = parse_reaction_list(v, source, "on_deny")?; - }, - Some("on_allow") => { - on_allow = parse_reaction_list(v, source, "on_allow")?; - }, - _ => { - // Non-reaction key — part of args (Cedar-style). - args_map.insert(k.clone(), v.clone()); - }, - } - } - - let args = match paren_args { - Some(s) => serde_yaml::Value::String(s.to_string()), - None => serde_yaml::Value::Mapping(args_map), - }; - - Ok((args, on_deny, on_allow)) -} - -fn parse_reaction_list( - v: &serde_yaml::Value, - source: &str, - which: &str, -) -> Result, ParseError> { - let list = v.as_sequence().ok_or_else(|| ParseError::Rule { - rule: format!("{:?}", v), - msg: format!("`{}:` must be a list of steps", which), - })?; - list.iter() - .enumerate() - .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i))) - .collect() -} - -/// Extract the args inside a call like `taint(X, Y)` or `plugin(foo)`. -/// Returns the substring between the outermost matching parens. -fn extract_call_args(line: &str, name: &str) -> Option { - let line = line.trim(); - if !line.starts_with(name) { - return None; - } - let after = &line[name.len()..]; - if !after.starts_with('(') { - return None; - } - // Find the matching close paren. - let bytes = after.as_bytes(); - let mut depth = 0; - for (i, &b) in bytes.iter().enumerate() { - match b { - b'(' => depth += 1, - b')' => { - depth -= 1; - if depth == 0 { - // Anything after the close paren is invalid. - if after[i + 1..].trim().is_empty() { - return Some(after[1..i].to_string()); - } - return None; - } - }, - _ => {}, - } - } - None -} - -/// Parse a pipe-chain string into a `Pipeline`. -/// -/// Splits on `|` (outside parens/quotes), trims each stage, parses each. -/// Empty pipelines (empty string or whitespace) are valid — they produce -/// `Pipeline { stages: vec![] }`. -pub fn parse_pipeline(src: &str) -> Result { - let mut pipeline = Pipeline::new(); - for seg in split_top_level(src.trim(), b'|') { - let seg = seg.trim(); - if seg.is_empty() { - continue; - } - pipeline.push(parse_stage(seg)?); - } - Ok(pipeline) -} - -/// Split `s` on `delim` at depth 0 — respects parens and quotes. -fn split_top_level(s: &str, delim: u8) -> Vec<&str> { - let bytes = s.as_bytes(); - let mut out = Vec::new(); - let mut depth: i32 = 0; - let mut in_quote: Option = None; - let mut start = 0; - for (i, &b) in bytes.iter().enumerate() { - match (in_quote, b) { - (Some(q), c) if c == q => in_quote = None, - (Some(_), _) => {}, - (None, b'"') | (None, b'\'') => in_quote = Some(b), - (None, b'(') | (None, b'[') => depth += 1, - (None, b')') | (None, b']') => depth -= 1, - (None, c) if c == delim && depth == 0 => { - out.push(&s[start..i]); - start = i + 1; - }, - _ => {}, - } - } - out.push(&s[start..]); - out -} - -fn parse_stage(src: &str) -> Result { - let s = src.trim(); - let bad = |msg: &str| ParseError::Predicate { - predicate: src.to_string(), - msg: msg.to_string(), - }; - - // Bare range literal: starts with `-`, digit, or `..`. - if let Some(stage) = try_parse_range(s) { - return Ok(stage); - } - - // Otherwise the stage starts with an identifier (keyword) optionally - // followed by `(args)`. - let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?; - - match (head, args.as_deref()) { - ("str", None) => Ok(Stage::Type(TypeCheck::Str)), - ("int", None) => Ok(Stage::Type(TypeCheck::Int)), - ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)), - ("float", None) => Ok(Stage::Type(TypeCheck::Float)), - ("email", None) => Ok(Stage::Type(TypeCheck::Email)), - ("url", None) => Ok(Stage::Type(TypeCheck::Url)), - ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)), - ("redact", None) => Ok(Stage::Redact { condition: None }), - ("omit", None) => Ok(Stage::Omit), - ("hash", None) => Ok(Stage::Hash), - ("mask", Some(a)) => parse_stage_mask(a, &bad), - ("redact", Some(a)) => parse_stage_redact_cond(a, src), - ("hash", Some(_)) => Err(bad("hash takes no arguments")), - ("omit", Some(_)) => Err(bad( - "omit takes no arguments — for conditional omit, use a policy rule predicate", - )), - ("len", Some(a)) => parse_stage_len(a, &bad), - ("enum", Some(a)) => parse_stage_enum(a, &bad), - ("regex", Some(a)) => Ok(parse_stage_regex(a)), - ("validate", Some(a)) => Err(parse_stage_validate_rejected(a, &bad)), - ("plugin" | "run", Some(a)) => parse_stage_plugin(head, a, &bad), - ("taint", Some(a)) => parse_taint(a, src), - - // Scan placeholders parse as bare identifiers. - ("pii.redact", None) => Ok(Stage::Scan { - kind: ScanKind::PiiRedact, - }), - ("pii.detect", None) => Ok(Stage::Scan { - kind: ScanKind::PiiDetect, - }), - ("injection.scan", None) => Ok(Stage::Scan { - kind: ScanKind::InjectionScan, - }), - - (other, _) => Err(bad(&format!("unknown stage `{}`", other))), - } -} - -fn parse_stage_mask(a: &str, bad: &impl Fn(&str) -> ParseError) -> Result { - let n: usize = a - .trim() - .parse() - .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?; - Ok(Stage::Mask { keep_last: n }) -} - -fn parse_stage_redact_cond(a: &str, src: &str) -> Result { - // redact(!perm.view_ssn) — argument is a predicate expression. - let cond = parse_predicate(a).map_err(|e| ParseError::Predicate { - predicate: src.to_string(), - msg: format!("invalid redact() condition: {}", e), - })?; - Ok(Stage::Redact { - condition: Some(cond), - }) -} - -fn parse_stage_len(a: &str, bad: &impl Fn(&str) -> ParseError) -> Result { - let (min, max) = parse_range_inner(a) - .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?; - let to_usize = |v: i64| -> Result { - if v < 0 { - Err(bad("len bounds must be non-negative")) - } else { - Ok(v as usize) - } - }; - Ok(Stage::Length { - min: min.map(to_usize).transpose()?, - max: max.map(to_usize).transpose()?, - }) -} - -fn parse_stage_enum(a: &str, bad: &impl Fn(&str) -> ParseError) -> Result { - let values = split_top_level(a, b',') - .into_iter() - .map(|v| { - let t = v.trim(); - // Allow either bare identifier or quoted string. - if (t.starts_with('"') && t.ends_with('"')) - || (t.starts_with('\'') && t.ends_with('\'')) - { - t[1..t.len() - 1].to_string() - } else { - t.to_string() - } - }) - .filter(|s| !s.is_empty()) - .collect::>(); - if values.is_empty() { - return Err(bad("enum() requires at least one value")); - } - Ok(Stage::Enum { values }) -} - -fn parse_stage_regex(a: &str) -> Stage { - let pattern = a.trim(); - let pat = if (pattern.starts_with('"') && pattern.ends_with('"')) - || (pattern.starts_with('\'') && pattern.ends_with('\'')) - { - pattern[1..pattern.len() - 1].to_string() - } else { - pattern.to_string() - }; - Stage::Regex { pattern: pat } -} - -// Named-validator dispatch (`validate(name)`) is in the spec -// but not implemented in this build — the evaluator's no-op stub would -// silently let invalid values through. Reject at compile time so -// operators notice immediately and reach for one of the working -// alternatives: -// -// * `regex("pattern")` — inline named-regex equivalent -// * `plugin(name)` — full plugin dispatch for rich validation (Luhn, -// format-with-context, etc.) -// -// When the ValidatorRegistry slice lands, this rejection flips back to -// returning `Stage::Validate { name }`. -fn parse_stage_validate_rejected(a: &str, bad: &impl Fn(&str) -> ParseError) -> ParseError { - bad(&format!( - "`validate({})` — named-validator dispatch is not implemented \ - in this build. Use `regex(\"pattern\")` for a named-regex \ - equivalent, or `plugin({})` for richer validation logic.", - a.trim(), - a.trim(), - )) -} - -fn parse_stage_plugin( - head: &str, - a: &str, - bad: &impl Fn(&str) -> ParseError, -) -> Result { - let name = a.trim(); - if name.is_empty() { - // Mirror the empty-name guard in `parse_step_string` so both the - // policy-step and field-stage paths reject a nameless `plugin()` - // / `run()` with the same diagnostic. - return Err(bad(&format!( - "`{head}(...)`: plugin name must not be empty" - ))); - } - Ok(Stage::Plugin { - name: name.to_string(), - }) -} - -/// Try to parse `s` as a bare range literal: `0..100`, `..500`, `0..`, `0..1M`. -fn try_parse_range(s: &str) -> Option { - if !s.contains("..") { - return None; - } - // Quick reject: must not start with a letter (would be a keyword). - let first = s.as_bytes().first().copied()?; - if first.is_ascii_alphabetic() || first == b'_' { - return None; - } - let (min, max) = parse_range_inner(s)?; - Some(Stage::Range { min, max }) -} - -/// Parse the inside of a range expression: `N..M`, `..M`, `N..`. -/// Returns `Some((min, max))` if shape is valid; `None` if it's not a range. -fn parse_range_inner(s: &str) -> Option<(Option, Option)> { - let dotdot = s.find("..")?; - let left = s[..dotdot].trim(); - let right = s[dotdot + 2..].trim(); - let min = if left.is_empty() { - None - } else { - Some(parse_numeric_with_suffix(left)?) - }; - let max = if right.is_empty() { - None - } else { - Some(parse_numeric_with_suffix(right)?) - }; - if min.is_none() && max.is_none() { - return None; // `..` alone isn't a useful range - } - Some((min, max)) -} - -/// Parse a number with optional `k/K` (×1000) or `m/M` (×1_000_000) suffix. -fn parse_numeric_with_suffix(s: &str) -> Option { - let s = s.trim(); - if s.is_empty() { - return None; - } - let (num_part, mult) = match s.as_bytes().last().copied()? { - b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64), - b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64), - _ => (s, 1_i64), - }; - let n: i64 = num_part.parse().ok()?; - n.checked_mul(mult) -} - -/// Split `s` (a stage form like `mask(4)`) into `(head, Some(args_inside_parens))` -/// or `(head, None)` if there are no parens. -fn split_head_args(s: &str) -> Option<(&str, Option)> { - if let Some(open) = s.find('(') { - // Match the corresponding closing paren at depth 0. - let bytes = s.as_bytes(); - let mut depth = 0; - let mut close = None; - for (i, &b) in bytes.iter().enumerate().skip(open) { - match b { - b'(' => depth += 1, - b')' => { - depth -= 1; - if depth == 0 { - close = Some(i); - break; - } - }, - _ => {}, - } - } - let close = close?; - let head = s[..open].trim(); - if head.is_empty() { - return None; - } - let args = s[open + 1..close].to_string(); - // Reject trailing garbage after the closing paren. - if s[close + 1..].trim().is_empty() { - Some((head, Some(args))) - } else { - None - } - } else { - let head = s.trim(); - if head.is_empty() { - None - } else { - Some((head, None)) - } - } -} - -fn parse_taint(args: &str, src: &str) -> Result { - // taint(label) | taint(label, session) | taint(label, [session, message]) - let parts = split_top_level(args, b','); - if parts.is_empty() { - return Err(ParseError::Predicate { - predicate: src.to_string(), - msg: "taint() requires at least a label".into(), - }); - } - let label = parts[0].trim().to_string(); - if label.is_empty() { - return Err(ParseError::Predicate { - predicate: src.to_string(), - msg: "taint label must not be empty".into(), - }); - } - - let scopes = if parts.len() == 1 { - vec![TaintScope::Session] // default - } else { - let scope_arg = parts[1..].join(","); - let scope_arg = scope_arg.trim(); - if scope_arg.starts_with('[') && scope_arg.ends_with(']') { - split_top_level(&scope_arg[1..scope_arg.len() - 1], b',') - .into_iter() - .map(|s| parse_taint_scope(s.trim(), src)) - .collect::, _>>()? - } else { - vec![parse_taint_scope(scope_arg, src)?] - } - }; - - Ok(Stage::Taint { label, scopes }) -} - -fn parse_taint_scope(s: &str, src: &str) -> Result { - match s { - "session" => Ok(TaintScope::Session), - "message" => Ok(TaintScope::Message), - other => Err(ParseError::Predicate { - predicate: src.to_string(), - msg: format!( - "unknown taint scope `{}` (expected `session` or `message`)", - other - ), - }), - } -} - -/// Top-level config — only the bits the parser understands. -/// -/// `policy_evaluator:`, `imports:`, `global:`, `defaults:`, `tags:`, -/// `plugin_dirs:`, `plugin_settings:`, `version:` are all accepted and -/// stored opaquely; this struct deserializes leniently. -/// -/// `plugins:` (the root block) is parsed into [`PluginDeclaration`]s so -/// the runtime can look up hook names + capabilities per plugin without -/// going back to the raw YAML. -#[derive(Debug, Default, Deserialize)] -pub struct ConfigYaml { - #[serde(default)] - pub routes: HashMap, - - /// Root `plugins:` block — full declarations. - #[serde(default)] - pub plugins: Vec, - - /// Anything else top-level goes here — picked up by later steps. - #[serde(flatten)] - pub other: HashMap, -} - -#[derive(Debug, Default, Deserialize)] -pub struct RouteYaml { - /// Flat pre-invocation authorization effects (was `policy:`). Each - /// entry is either a string (rule / plugin / taint) or a single-key - /// map (PDP call with reactions). See `parse_step`. Merged with any - /// `authorization.pre_invocation` entries. - #[serde(default)] - pub pre_invocation: Vec, - - /// Flat post-invocation authorization effects (was `post_policy:`). - /// Merged with any `authorization.post_invocation` entries. - #[serde(default)] - pub post_invocation: Vec, - - /// Nested `authorization:` block — `{ pre_invocation, post_invocation }`. - /// Equivalent to the flat forms; entries from both are concatenated. - #[serde(default)] - pub authorization: Option, - - /// `args:` field → pipe-chain string. Compiled to per-field pipelines. - #[serde(default)] - pub args: HashMap, - - /// `result:` field → pipe-chain string. Compiled to per-field pipelines. - #[serde(default)] - pub result: HashMap, - - /// Per-route plugin overrides — only the spec-overridable keys - /// (config / capabilities / on_error). Merged on top of the root - /// `plugins:` declaration at dispatch time. - #[serde(default)] - pub plugins: HashMap, - - /// Anything else on the route (meta, taint, when) — stashed. Also - /// where renamed legacy keys land; `reject_legacy_keys` fails loudly - /// on them so a dropped authz block never fails open. - #[serde(flatten)] - pub other: HashMap, -} - -/// Nested `authorization:` block. Both sub-lists are optional and default -/// to empty; each is compiled the same way as the flat `pre_invocation:` / -/// `post_invocation:` forms. -/// -/// `deny_unknown_fields` is load-bearing: without it a legacy key nested -/// under the wrapper (`authorization: { policy: [...] }`) would be silently -/// dropped by serde — both lists empty, no error, no authorization enforced -/// (a fail-open). The top-level `reject_legacy_keys` can't catch it because -/// the key is consumed as part of the `authorization` value and never lands -/// in `RouteYaml.other`. Denying unknown fields turns that into a load error -/// and also catches typos like `pre_invocaton:`. Safe here because the struct -/// has no `#[serde(flatten)]`. -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AuthorizationYaml { - #[serde(default)] - pub pre_invocation: Vec, - - #[serde(default)] - pub post_invocation: Vec, -} - -/// Output of [`compile_config`] — the routes that have APL blocks plus -/// the registry of plugin declarations from the root `plugins:` block. -/// -/// The two travel together because the evaluator needs both: the route -/// gives it the steps to run, and the registry gives the dispatcher the -/// hook name / kind for each plugin name referenced by those steps. -#[derive(Debug, Default)] -pub struct CompiledConfig { - pub routes: HashMap, - pub plugins: PluginRegistry, -} - -/// Compile a YAML config into a [`CompiledConfig`] (routes + plugin -/// registry). -/// -/// Routes with no APL fields populated (no `authorization:` / -/// `pre_invocation:` / `post_invocation:` / `args:` / `result:`) are -/// **omitted from `routes`**, per apl-design -/// "Routes without APL blocks fall back to legacy plugin-chain execution." -/// A route-level `plugins:` override block alone is not enough — overrides -/// only have meaning when the route actually dispatches plugins via APL -/// steps, so an override-only route is treated as legacy. -pub fn compile_config(yaml: &str) -> Result { - let cfg: ConfigYaml = serde_yaml::from_str(yaml)?; - let mut routes = HashMap::with_capacity(cfg.routes.len()); - for (route_key, raw) in cfg.routes { - if let Some(route) = compile_route(&route_key, raw)? { - routes.insert(route_key, route); - } - } - let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len()); - for decl in cfg.plugins { - // Duplicate plugin names: last-one-wins for v0. The spec doesn't - // currently prescribe an error here; flag if real configs hit it. - plugins.insert(decl.name.clone(), decl); - } - Ok(CompiledConfig { routes, plugins }) -} - -/// Legacy field names, mapped to their replacements. Because unknown keys -/// land in `RouteYaml.other` via `#[serde(flatten)]`, a config still using -/// an old name would otherwise be *silently dropped* — dropping a `policy:` -/// block fails open (no authorization enforced). We reject them loudly -/// instead. `identity` is renamed in cpex-core config, not here. -const RENAMED_FIELDS: [(&str, &str); 2] = [ - ( - "policy", - "authorization.pre_invocation (or flat pre_invocation)", - ), - ( - "post_policy", - "authorization.post_invocation (or flat post_invocation)", - ), -]; - -/// Fail loudly if a stashed key is a renamed legacy field, so a dropped -/// authz block never fails open. Run before the has-APL gate. -fn reject_legacy_keys( - location: &str, - other: &HashMap, -) -> Result<(), ParseError> { - for (old, new) in RENAMED_FIELDS { - if other.contains_key(old) { - return Err(ParseError::RenamedField { - location: location.to_string(), - old: old.to_string(), - new: new.to_string(), - }); - } - } - Ok(()) -} - -fn compile_route(route_key: &str, raw: RouteYaml) -> Result, ParseError> { - // Reject legacy keys *before* the gate: a legacy-only route would - // otherwise look empty and be silently omitted (fail open). - reject_legacy_keys(route_key, &raw.other)?; - let has_authz = raw - .authorization - .as_ref() - .is_some_and(|a| !a.pre_invocation.is_empty() || !a.post_invocation.is_empty()); - let has_apl = !raw.pre_invocation.is_empty() - || !raw.post_invocation.is_empty() - || has_authz - || !raw.args.is_empty() - || !raw.result.is_empty(); - if !has_apl { - return Ok(None); - } - Ok(Some(compile_apl_blocks(route_key, raw)?)) -} - -/// Compile the APL bodies (authorization/args/result/plugins) of a -/// single block into a `CompiledRoute`. Doesn't gate on "has any APL -/// fields" — callers that need the gate (compile_config) check first. -/// `source` is the path prefix baked into rule/pipeline diagnostics -/// (e.g. `"global.policy.all"`, `"route.get_compensation"`). -/// -/// The nested `authorization:` block and the flat `pre_invocation:` / -/// `post_invocation:` forms are equivalent alternatives. Declaring the same -/// phase in both forms on one section is rejected (it would run the effects -/// twice); only one form may carry a given phase per section. -fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result { - reject_legacy_keys(source, &raw.other)?; - let mut route = CompiledRoute::new(source); - let (auth_pre, auth_post) = raw - .authorization - .map(|a| (a.pre_invocation, a.post_invocation)) - .unwrap_or_default(); - // Reject declaring the same phase both nested and flat on one section: - // the two forms are alternatives, not additive, so merging them would - // run each effect twice (a `run(...)` / `delegate(...)` double-fire). - // Stacking across *different* scopes (e.g. global nested + route flat) - // is fine — those are separate `compile_apl_blocks` calls. - if !auth_pre.is_empty() && !raw.pre_invocation.is_empty() { - return Err(ParseError::ConflictingAuthorizationForms { - location: source.to_string(), - phase: "pre_invocation".to_string(), - }); - } - if !auth_post.is_empty() && !raw.post_invocation.is_empty() { - return Err(ParseError::ConflictingAuthorizationForms { - location: source.to_string(), - phase: "post_invocation".to_string(), - }); - } - for (i, entry) in auth_pre.iter().chain(raw.pre_invocation.iter()).enumerate() { - let step = parse_step(entry, &format!("{}.pre_invocation[{}]", source, i))?; - route.policy.push(step_to_top_level_effect(step)?); - } - for (i, entry) in auth_post - .iter() - .chain(raw.post_invocation.iter()) - .enumerate() - { - let step = parse_step(entry, &format!("{}.post_invocation[{}]", source, i))?; - route.post_policy.push(step_to_top_level_effect(step)?); - } - for (field, chain) in &raw.args { - let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { - rule: format!("args.{}: {:?}", field, chain), - msg: format!("{}", e), - })?; - route.args.push(FieldRule { - field: field.clone(), - pipeline, - source: format!("{}.args.{}", source, field), - }); - } - for (field, chain) in &raw.result { - let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { - rule: format!("result.{}: {:?}", field, chain), - msg: format!("{}", e), - })?; - route.result.push(FieldRule { - field: field.clone(), - pipeline, - source: format!("{}.result.{}", source, field), - }); - } - route.plugin_overrides = raw.plugins; - Ok(route) -} - -/// Compile a single APL policy block from a `serde_yaml::Value` whose -/// shape is the body of a route's `apl:` block: -/// -/// ```yaml -/// args: -/// employee_id: "str" -/// authorization: -/// pre_invocation: -/// - "require(authenticated)" -/// post_invocation: -/// - "taint(forward)" -/// result: -/// ssn: "redact(!perm.view_ssn)" -/// ``` -/// -/// Used by external orchestrators (apl-cpex's `AplConfigVisitor`) that -/// have already located an APL block inside a larger unified-config -/// YAML. `source` is woven into per-rule / per-pipeline diagnostic paths. -/// Returns an empty `CompiledRoute` when the value is null or contains -/// no APL fields — callers that want a "is this empty?" gate can check -/// `declared_phases().is_empty()` on the result. -pub fn compile_policy_block_value( - source: &str, - block: &serde_yaml::Value, -) -> Result { - if block.is_null() { - return Ok(CompiledRoute::new(source)); - } - let raw: RouteYaml = serde_yaml::from_value(block.clone())?; - compile_apl_blocks(source, raw) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::attributes::AttributeBag; - use crate::evaluator::Decision; - - #[test] - fn lex_basic() { - let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap(); - assert_eq!( - toks, - vec![ - Tok::Ident("delegation.depth".into()), - Tok::Gt, - Tok::IntLit(2), - ] - ); - } - - #[test] - fn lex_strings_both_quotes() { - let a = Lexer::new(r#""double""#).tokenize_all().unwrap(); - let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap(); - assert_eq!(a, vec![Tok::StringLit("double".into())]); - assert_eq!(b, vec![Tok::StringLit("single".into())]); - } - - #[test] - fn lex_keywords_vs_idents() { - let toks = Lexer::new("require(role.hr) & authenticated") - .tokenize_all() - .unwrap(); - assert_eq!( - toks, - vec![ - Tok::Require, - Tok::LParen, - Tok::Ident("role.hr".into()), - Tok::RParen, - Tok::And, - Tok::Ident("authenticated".into()), - ] - ); - } - - #[test] - fn lex_rejects_single_equals() { - let err = Lexer::new("a = 1").tokenize_all().unwrap_err(); - assert!(format!("{}", err).contains("expected `==`")); - } - - // ----- interpolated attribute paths ----- - - #[test] - fn lex_interpolated_path_is_one_ident() { - let toks = Lexer::new("data.tenants[subject.tenant].data_region") - .tokenize_all() - .unwrap(); - assert_eq!( - toks, - vec![Tok::Ident( - "data.tenants[subject.tenant].data_region".into() - )] - ); - } - - #[test] - fn lex_interpolated_path_in_comparison() { - let toks = Lexer::new("data.tenants[subject.tenant].data_region == 'eu'") - .tokenize_all() - .unwrap(); - assert_eq!( - toks, - vec![ - Tok::Ident("data.tenants[subject.tenant].data_region".into()), - Tok::Eq, - Tok::StringLit("eu".into()), - ] - ); - } - - #[test] - fn lex_rejects_unterminated_bracket() { - let err = Lexer::new("data.tenants[subject.tenant") - .tokenize_all() - .unwrap_err(); - assert!(format!("{}", err).contains("unterminated"), "got: {}", err); - } - - #[test] - fn lex_rejects_nested_bracket() { - let err = Lexer::new("data.x[a[b]]").tokenize_all().unwrap_err(); - assert!(format!("{}", err).contains("nested"), "got: {}", err); - } - - #[test] - fn interpolated_predicate_parses_to_comparison() { - let e = parse_predicate("data.tenants[subject.tenant].data_region == 'eu'").unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::Comparison { - key: "data.tenants[subject.tenant].data_region".into(), - op: CompareOp::Eq, - value: Literal::String("eu".into()), - }) - ); - } - - // ----- Predicate parser ----- - - #[test] - fn pred_bare_identifier() { - let e = parse_predicate("authenticated").unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::IsTrue { - key: "authenticated".into() - }) - ); - } - - #[test] - fn pred_comparison() { - let e = parse_predicate("delegation.depth > 2").unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::Comparison { - key: "delegation.depth".into(), - op: CompareOp::Gt, - value: Literal::Int(2), - }) - ); - } - - #[test] - fn pred_contains() { - let e = parse_predicate(r#"session.labels contains "PII""#).unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::Comparison { - key: "session.labels".into(), - op: CompareOp::Contains, - value: Literal::String("PII".into()), - }) - ); - } - - #[test] - fn pred_precedence_or_lowest_and_middle_not_highest() { - // `!a & b | c` should parse as `(!a & b) | c`. - let e = parse_predicate("!a & b | c").unwrap(); - match e { - Expression::Or(parts) => { - assert_eq!(parts.len(), 2); - match &parts[0] { - Expression::And(_) => {}, - other => panic!("first OR branch should be AND, got {:?}", other), - } - }, - other => panic!("top-level should be OR, got {:?}", other), - } - } - - #[test] - fn pred_parens_override_precedence() { - // `(role.finance | role.admin) & !delegated`. - let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap(); - match e { - Expression::And(parts) => { - assert_eq!(parts.len(), 2); - matches!(parts[0], Expression::Or(_)); - matches!(parts[1], Expression::Not(_)); - }, - other => panic!("expected top-level AND, got {:?}", other), - } - } - - #[test] - fn pred_require_rejected_as_predicate() { - // require() is a rule-level shorthand, not a sub-predicate. - // Trying to use it inside a predicate expression must fail clearly. - let err = parse_predicate("require(authenticated)").unwrap_err(); - assert!(format!("{}", err).contains("rule-level shorthand")); - } - - #[test] - fn rule_require_single_arg_desugars_to_isfalse_and_deny() { - // require(X) → Rule { condition: IsFalse(X), action: Deny } - let r = parse_rule("require(authenticated)", "test").unwrap(); - assert!(matches!( - r.effects.as_slice(), - [Effect::Deny { - reason: None, - code: None - }] - )); - assert_eq!( - r.condition, - Expression::Condition(Condition::IsFalse { - key: "authenticated".into() - }), - ); - } - - #[test] - fn rule_require_comma_is_and_desugars_to_or_of_isfalse() { - // require(X, Y) → Or([IsFalse(X), IsFalse(Y)]) + Deny - // i.e., "deny if any are falsy" = "any are falsy → deny" - let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap(); - assert_eq!( - r.condition, - Expression::Or(vec![ - Expression::Condition(Condition::IsFalse { - key: "role.hr".into() - }), - Expression::Condition(Condition::IsFalse { - key: "perm.view_ssn".into() - }), - ]), - ); - } - - #[test] - fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() { - // require(X | Y) → And([IsFalse(X), IsFalse(Y)]) + Deny - // i.e., "deny only if all are falsy" = "all are falsy → deny" - let r = parse_rule("require(role.finance | role.admin)", "test").unwrap(); - assert_eq!( - r.condition, - Expression::And(vec![ - Expression::Condition(Condition::IsFalse { - key: "role.finance".into() - }), - Expression::Condition(Condition::IsFalse { - key: "role.admin".into() - }), - ]), - ); - } - - #[test] - fn rule_require_mixed_rejected() { - let err = parse_rule("require(a, b | c)", "test").unwrap_err(); - assert!(format!("{}", err).contains("cannot mix")); - } - - #[test] - fn pred_eq_with_ident_rhs_rejected_with_in_hint() { - // `subject.type == allowed_types` — `==` doesn't take an ident RHS, - // and the error should hint at `in` for set membership. - let err = parse_predicate("subject.type == allowed_types").unwrap_err(); - let msg = format!("{}", err); - assert!(msg.contains("RHS-as-identifier")); - assert!(msg.contains("set membership use")); - } - - #[test] - fn pred_in_set_basic() { - let e = parse_predicate("subject.type in allowed_types").unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::InSet { - value_key: "subject.type".into(), - set_key: "allowed_types".into(), - negate: false, - }), - ); - } - - #[test] - fn pred_not_in_set() { - let e = parse_predicate("subject.type not in blocked_types").unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::InSet { - value_key: "subject.type".into(), - set_key: "blocked_types".into(), - negate: true, - }), - ); - } - - #[test] - fn pred_exists_basic() { - let e = parse_predicate("exists(args.amount)").unwrap(); - assert_eq!( - e, - Expression::Condition(Condition::Exists { - key: "args.amount".into() - }), - ); - } - - #[test] - fn pred_exists_inside_compound() { - // exists() is a sub-predicate (unlike require) — can nest in & / |. - let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap(); - match e { - Expression::And(parts) => { - assert_eq!(parts.len(), 2); - assert_eq!( - parts[0], - Expression::Condition(Condition::Exists { - key: "args.amount".into() - }), - ); - }, - other => panic!("expected And, got {:?}", other), - } - } - - #[test] - fn pred_exists_requires_paren_and_ident() { - assert!(parse_predicate("exists").is_err()); - assert!(parse_predicate("exists()").is_err()); - assert!(parse_predicate("exists(authenticated").is_err()); - } - - #[test] - fn pred_trailing_tokens_rejected() { - let err = parse_predicate("a b").unwrap_err(); - assert!(format!("{}", err).contains("trailing")); - } - - #[test] - fn rule_predicate_action_form() { - let r = parse_rule("delegation.depth > 2: deny", "test").unwrap(); - match r.effects.as_slice() { - [Effect::Deny { .. }] => {}, - other => panic!("expected [Deny], got {:?}", other), - } - match r.condition { - Expression::Condition(Condition::Comparison { .. }) => {}, - other => panic!("expected Comparison, got {:?}", other), - } - } - - #[test] - fn rule_predicate_only_defaults_to_deny() { - // Missing action defaults to deny. - let r = parse_rule("!authenticated", "test").unwrap(); - assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }])); - } - - #[test] - fn rule_explicit_allow() { - let r = parse_rule("role.admin: allow", "test").unwrap(); - assert!(matches!(r.effects.as_slice(), [Effect::Allow])); - } - - #[test] - fn rule_bare_action_unconditional() { - // Bare `- deny` and `- allow` are unconditional rules with - // Expression::Always as the predicate. - let r = parse_rule("deny", "test").unwrap(); - assert_eq!(r.condition, Expression::Always); - assert!(matches!( - r.effects.as_slice(), - [Effect::Deny { - reason: None, - code: None - }] - )); - - let r = parse_rule("allow", "test").unwrap(); - assert_eq!(r.condition, Expression::Always); - assert!(matches!(r.effects.as_slice(), [Effect::Allow])); - } - - #[test] - fn rule_bare_deny_call_carries_reason_and_code() { - // Unconditional `deny('reason')` / `deny('reason', 'code')` parse - // to an Always-guarded Deny, so they're usable as bare rule lines - // and as `on_deny:` / `on_allow:` reactions. - let r = parse_rule("deny('nope')", "test").unwrap(); - assert_eq!(r.condition, Expression::Always); - match r.effects.as_slice() { - [Effect::Deny { - reason: Some(reason), - code: None, - }] => assert_eq!(reason, "nope"), - other => panic!( - "expected [Deny{{reason: Some, code: None}}], got {:?}", - other - ), - } - - let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap(); - assert_eq!(r.condition, Expression::Always); - match r.effects.as_slice() { - [Effect::Deny { - reason: Some(reason), - code: Some(code), - }] => { - assert_eq!(reason, "nope"); - assert_eq!(code, "cel.policy"); - }, - other => panic!("expected [Deny{{reason, code}}], got {:?}", other), - } - } - - #[test] - fn rule_malformed_bare_deny_call_errors() { - // A malformed `deny(...)` must surface its own error rather than - // falling through to the predicate parser. - let err = parse_rule("deny(unquoted)", "test").unwrap_err(); - assert!( - matches!(err, ParseError::Rule { .. }), - "expected ParseError::Rule, got {:?}", - err - ); - } - - #[test] - fn rule_step_kinds_rejected_clearly() { - for s in [ - "plugin(rate_limiter)", - "cedar:(action: read)", - "opa(path)", - "taint(audit)", - ] { - let err = parse_rule(s, "test").unwrap_err(); - assert!( - matches!(err, ParseError::UnsupportedStep { .. }), - "expected UnsupportedStep for `{}`, got {:?}", - s, - err - ); - } - } - - #[test] - fn rule_deny_with_unquoted_arg_rejected() { - // `deny "reason"` (space-separated, no parens) is not a valid - // form. The supported reason-carrying shape is - // `deny('reason')` / `deny('reason', 'code')` and - // the `code` extension. - let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err(); - assert!(format!("{}", err).contains("unsupported action")); - } - - #[test] - fn rule_deny_with_quoted_reason_accepted() { - // `deny('reason')` — single-arg form. Reason landing on the - // effect; code defaulting to None. - let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap(); - assert!(matches!( - r.effects.as_slice(), - [Effect::Deny { reason: Some(s), code: None }] if s == "too deep" - )); - } - - #[test] - fn rule_deny_with_reason_and_code_accepted() { - // `deny('reason', 'code')` — extension. Both reason and - // author-supplied code surface in the violation. - let r = parse_rule( - r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#, - "test", - ) - .unwrap(); - match r.effects.as_slice() { - [Effect::Deny { - reason: Some(reason), - code: Some(code), - }] => { - assert_eq!(reason, "too deep"); - assert_eq!(code, "delegation.depth_exceeded"); - }, - other => panic!("expected Deny with reason+code, got {:?}", other), - } - } - - #[test] - fn rule_deny_with_too_many_args_rejected() { - // Cap on positional args — `deny(reason, code)` is the limit. - let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err(); - assert!(format!("{}", err).contains("at most two args")); - } - - #[test] - fn rule_deny_with_unquoted_args_in_call_rejected() { - // The args MUST be quoted; bare identifiers aren't legal. - let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err(); - assert!(format!("{}", err).contains("expected a quoted string")); - } - - fn parse_step_yaml(yaml: &str) -> Result { - let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - parse_step(&v, "test") - } - - #[test] - fn when_do_single_effect_deny() { - // do: deny — single string value, no list. - let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap(); - match step { - Step::Rule(rule) => { - assert!(matches!( - rule.condition, - Expression::Condition(Condition::Comparison { .. }) - )); - assert!(matches!( - rule.effects.as_slice(), - [Effect::Deny { - reason: None, - code: None - }] - )); - }, - other => panic!("expected Step::Rule, got {:?}", other), - } - } - - #[test] - fn when_do_single_effect_deny_with_reason_and_code() { - // The `deny('reason', 'code')` extension works inside `do:` too. - let step = parse_step_yaml( - "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')", - ) - .unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - match rule.effects.as_slice() { - [Effect::Deny { - reason: Some(r), - code: Some(c), - }] => { - assert_eq!(r, "too deep"); - assert_eq!(c, "delegation.depth_exceeded"); - }, - other => panic!("expected Deny+reason+code, got {:?}", other), - } - } - - #[test] - fn when_do_multi_effect_list() { - // The headline demo case: fan-out from one predicate. - // do: [plugin(audit_logger), taint(unauth), deny('refused')] - let yaml = r#" -when: "!role.hr" -do: - - "plugin(audit_logger)" - - "taint(unauth, session)" - - "deny('refused', 'role.hr_required')" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - assert_eq!(rule.effects.len(), 3); - assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger")); - assert!(matches!( - rule.effects[1], - Effect::Taint { ref label, .. } if label == "unauth" - )); - match &rule.effects[2] { - Effect::Deny { - reason: Some(r), - code: Some(c), - } => { - assert_eq!(r, "refused"); - assert_eq!(c, "role.hr_required"); - }, - other => panic!("expected Deny+reason+code, got {:?}", other), - } - } - - #[test] - fn when_do_key_order_does_not_matter() { - // YAML maps are unordered; `do:` first should parse the same. - let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap(); - assert!(matches!(step, Step::Rule(_))); - } - - #[test] - fn when_do_with_unknown_key_rejected() { - // Typo guard — surface unknown keys instead of silently dropping. - let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err(); - assert!(format!("{}", err).contains("unexpected key")); - } - - #[test] - fn when_do_empty_do_list_rejected() { - // An empty `do:` is almost certainly an author mistake; - // require at least one effect. - let err = parse_step_yaml("when: x\ndo: []").unwrap_err(); - assert!(format!("{}", err).contains("no effects")); - } - - #[test] - fn shorthand_multi_effect_map() { - // Shorthand for the canonical when/do form. The predicate is - // the map's only key, the value is a list of effects. - let yaml = r#" -"!role.hr": - - "plugin(audit_logger)" - - "deny('unauthorized')" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - assert_eq!(rule.effects.len(), 2); - assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger")); - assert!(matches!( - rule.effects[1], - Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized" - )); - } - - #[test] - fn shorthand_multi_effect_map_with_nested_delegate() { - // Map-form effects (like `delegate:`) work inside a shorthand - // list, exercising the parse_effect_value path. - let yaml = r#" -"role.hr": - - delegate: - plugin: workday-oauth - config: - audience: workday-api - - "plugin(audit_logger)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - assert_eq!(rule.effects.len(), 2); - assert!(matches!(rule.effects[0], Effect::Delegate(_))); - assert!(matches!(rule.effects[1], Effect::Plugin { .. })); - } - - #[test] - fn cedar_with_list_body_still_parses_as_pdp() { - // Regression guard — `cedar:` and other PDP keys whose body - // happens to be list-shaped (e.g. when the author embeds a - // bare reaction list) must NOT be reinterpreted as a - // shorthand multi-effect map. - // - // Cedar bodies in production are maps with `action`/`resource` - // keys — we don't actually accept a Sequence body, but the - // shorthand-list detector explicitly excludes known PDP - // dialect keys so the failure mode here is the existing PDP - // body error, not a shorthand misparse. - let err = parse_step_yaml("cedar: [oh no]").unwrap_err(); - // Existing PDP body validator complains about the shape — - // proves we didn't try to read `cedar` as a predicate. - assert!(format!("{}", err).contains("body must be a map")); - } - - #[test] - fn shorthand_multi_effect_empty_list_rejected() { - let err = parse_step_yaml(r#""x": []"#).unwrap_err(); - assert!(format!("{}", err).contains("no effects")); - } - - #[test] - fn when_do_with_field_op_result_redact() { - // The headline case: `result.salary | redact` as an effect - // inside a do: list, alongside other effect kinds. - let yaml = r#" -when: "!perm.view_ssn" -do: - - "plugin(audit_logger)" - - "result.salary | redact" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - assert_eq!(rule.effects.len(), 2); - assert!(matches!(rule.effects[0], Effect::Plugin { .. })); - match &rule.effects[1] { - Effect::FieldOp { path, stages } => { - assert_eq!(path, "result.salary"); - assert_eq!(stages.len(), 1, "single `redact` stage"); - }, - other => panic!("expected FieldOp, got {:?}", other), - } - } - - #[test] - fn when_do_with_field_op_args_mask() { - // `args.card_number | mask(4)` — args side + parametrised stage. - let yaml = r#" -when: role.support -do: "args.card_number | mask(4)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - match &rule.effects[..] { - [Effect::FieldOp { path, stages }] => { - assert_eq!(path, "args.card_number"); - assert_eq!(stages.len(), 1); - }, - other => panic!("expected single FieldOp, got {:?}", other), - } - } - - #[test] - fn when_do_with_chained_field_op() { - // Chained stages — type check + content effect. Uses stages - // the pipeline parser actually knows about (`str` and `mask`). - let yaml = r#" -when: role.support -do: "args.card_number | str | mask(4)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - match &rule.effects[..] { - [Effect::FieldOp { path, stages }] => { - assert_eq!(path, "args.card_number"); - assert_eq!(stages.len(), 2, "two-stage chain"); - }, - other => panic!("expected single FieldOp, got {:?}", other), - } - } - - #[test] - fn field_stage_run_aliases_plugin() { - // In a field pipeline, `run(name)` is the same plugin-transform - // stage as `plugin(name)` — symmetry with the policy-step alias. - let yaml = r#" -when: role.support -do: "args.card_number | run(luhn)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - match &rule.effects[..] { - [Effect::FieldOp { path, stages }] => { - assert_eq!(path, "args.card_number"); - match &stages[..] { - [Stage::Plugin { name }] => assert_eq!(name, "luhn"), - other => panic!("expected [Stage::Plugin], got {:?}", other), - } - }, - other => panic!("expected single FieldOp, got {:?}", other), - } - } - - #[test] - fn field_stage_plugin_empty_name_is_rejected() { - // `plugin()` / `run()` with no name in a field pipeline must be - // rejected, mirroring the policy-step path (`parse_step_string`). - for verb in ["plugin", "run"] { - let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error"); - let msg = format!("{err}"); - assert!( - msg.contains(verb) && msg.contains("must not be empty"), - "{verb}(): expected verb-named empty-name error, got: {msg}" - ); - } - } - - #[test] - fn field_op_invalid_path_falls_through() { - // `role.hr | redact` looks like a pipe chain but the path - // doesn't start with `args.` / `result.`. We refuse to treat - // it as a FieldOp; instead it falls through to the predicate - // parser, which will fail with a more specific error. - let yaml = r#"do: "role.hr | redact""#; - let _ = parse_step_yaml(&format!("when: true\n{}", yaml)); - // The exact failure mode here isn't load-bearing — what matters - // is we don't silently produce an unconditional FieldOp with a - // bogus path. So just confirm we either error or produce - // *something other than* a FieldOp. - let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\""); - match step { - Ok(Step::Rule(rule)) => { - assert!( - !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]), - "bare `role.hr` must NOT parse as a FieldOp path" - ); - }, - Err(_) => {}, // also fine - other => panic!("unexpected: {:?}", other), - } - } - - #[test] - fn field_op_empty_chain_rejected() { - // `args.x |` (trailing pipe with nothing after) — author bug. - let yaml = r#"when: true -do: "args.x | ""#; - let _ = parse_step_yaml(yaml); // shape varies by YAML parser, just ensure no panic - } - - #[test] - fn shorthand_multi_effect_with_field_op() { - // Shorthand `predicate: [list]` with a content effect. - let yaml = r#" -"!perm.view_ssn": - - "plugin(audit_logger)" - - "result.ssn | redact" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Step::Rule"); - }; - assert_eq!(rule.effects.len(), 2); - assert!(matches!(rule.effects[1], Effect::FieldOp { .. })); - } - - #[test] - fn find_top_level_pipe_skips_inside_parens() { - // Top-level `|` between path and chain → returns its index. - // Inner `|` inside `(...)` or quotes is ignored. - assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7)); - assert_eq!(find_top_level_pipe("validate(luhn)"), None); - assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7)); - // No top-level pipe even with a `|` inside the parameter set. - assert_eq!(find_top_level_pipe("mask(a|b)"), None); - } - - #[test] - fn top_level_sequential() { - // `- sequential: [list]` as a top-level policy step. - let yaml = r#" -sequential: - - "plugin(rate_limiter)" - - "plugin(audit_logger)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule"); - }; - assert!(matches!(rule.condition, Expression::Always)); - match rule.effects.as_slice() { - [Effect::Sequential(inner)] => { - assert_eq!(inner.len(), 2); - assert!(matches!(inner[0], Effect::Plugin { .. })); - assert!(matches!(inner[1], Effect::Plugin { .. })); - }, - other => panic!("expected single Sequential effect, got {:?}", other), - } - } - - #[test] - fn top_level_parallel() { - let yaml = r#" -parallel: - - "plugin(pii_scanner)" - - "plugin(nemo_guardrails)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule"); - }; - match rule.effects.as_slice() { - [Effect::Parallel(inner)] => { - assert_eq!(inner.len(), 2); - }, - other => panic!("expected single Parallel effect, got {:?}", other), - } - } - - #[test] - fn parallel_inside_do_body() { - // The DSL spec's "Conditional parallel" example: a `when:` - // rule whose `do:` is a single parallel block. - let yaml = r#" -when: args.include_ssn == true -do: - parallel: - - "plugin(pii_scanner)" - - "plugin(nemo_guardrails)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule"); - }; - match rule.effects.as_slice() { - [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2), - other => panic!("expected Parallel in do:, got {:?}", other), - } - } - - #[test] - fn parallel_rejects_field_op_at_parse_time() { - // FieldOp inside Parallel should fail at parse, not at runtime. - let yaml = r#" -parallel: - - "plugin(audit)" - - "args.ssn | redact" -"#; - let err = parse_step_yaml(yaml).unwrap_err(); - assert!(format!("{}", err).contains("mutation"), "got: {}", err); - } - - #[test] - fn parallel_rejects_delegate_at_parse_time() { - let yaml = r#" -parallel: - - "plugin(audit)" - - "delegate(workday)" -"#; - let err = parse_step_yaml(yaml).unwrap_err(); - assert!(format!("{}", err).contains("mutation")); - } - - #[test] - fn sequential_allows_mutations() { - // The escape valve — Sequential lets mutations through. - let yaml = r#" -sequential: - - "args.ssn | redact" - - "plugin(audit)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule") - }; - match rule.effects.as_slice() { - [Effect::Sequential(inner)] => { - assert!(matches!(inner[0], Effect::FieldOp { .. })); - assert!(matches!(inner[1], Effect::Plugin { .. })); - }, - other => panic!("got {:?}", other), - } - } - - #[test] - fn parallel_empty_list_rejected() { - let err = parse_step_yaml("parallel: []").unwrap_err(); - assert!(format!("{}", err).contains("empty")); - } - - #[test] - fn sequential_empty_list_rejected() { - let err = parse_step_yaml("sequential: []").unwrap_err(); - assert!(format!("{}", err).contains("empty")); - } - - // ----- restrict effect ----- - - /// A literal `StringSetSpec` for terse assertions. - fn lit(items: &[&str]) -> Option { - Some(crate::constraint::StringSetSpec::Literal( - items.iter().map(|s| s.to_string()).collect(), - )) - } - - #[test] - fn top_level_restrict_full_shape() { - // Every field exercised at once, including `custom` scalar - // coercion and an explicit `on_empty`. - let yaml = r#" -restrict: - allow_models: ["vllm/*", "anthropic/claude-sonnet-*"] - deny_models: ["openai/*"] - allow_regions: [eu] - allow_sites: [site-a] - max_cost_tier: cheap - custom: { gpu: h100, dedicated: true } - on_empty: fallback -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Restrict { spec } = step else { - panic!("expected Step::Restrict, got {:?}", step); - }; - use crate::constraint::OnEmpty; - assert_eq!( - spec.allow_models, - lit(&["vllm/*", "anthropic/claude-sonnet-*"]) - ); - assert_eq!(spec.deny_models, lit(&["openai/*"])); - assert_eq!(spec.allow_regions, lit(&["eu"])); - assert_eq!(spec.allow_sites, lit(&["site-a"])); - assert_eq!(spec.max_cost_tier.as_deref(), Some("cheap")); - // `custom` coerces the bool `true` to the string "true". - assert_eq!(spec.custom.get("gpu"), Some(&"h100".to_string())); - assert_eq!(spec.custom.get("dedicated"), Some(&"true".to_string())); - assert_eq!(spec.on_empty, OnEmpty::Fallback); - } - - #[test] - fn restrict_on_empty_defaults_to_deny() { - let step = parse_step_yaml("restrict: { deny_models: [\"openai/*\"] }").unwrap(); - let Step::Restrict { spec } = step else { - panic!("expected Step::Restrict"); - }; - assert_eq!(spec.on_empty, crate::constraint::OnEmpty::Deny); - } - - #[test] - fn restrict_field_reference_parses_as_ref() { - // A scalar `data.*` path is a reference, not a literal. A path - // containing `[...]` must be quoted so YAML doesn't read the - // brackets as a flow sequence (block form works unquoted too). - let yaml = r#" -restrict: - allow_models: "data.agents[subject.id].allowed_models" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Restrict { spec } = step else { - panic!("expected Step::Restrict"); - }; - assert_eq!( - spec.allow_models, - Some(crate::constraint::StringSetSpec::Ref( - "data.agents[subject.id].allowed_models".to_string() - )) - ); - } - - #[test] - fn restrict_bracketless_reference_parses_unquoted() { - // A reference with no `[...]` is a clean plain scalar — no quoting - // needed even in flow form. - let step = parse_step_yaml("restrict: { allow_regions: data.tenant_regions }").unwrap(); - let Step::Restrict { spec } = step else { - panic!("expected Step::Restrict"); - }; - assert_eq!( - spec.allow_regions, - Some(crate::constraint::StringSetSpec::Ref( - "data.tenant_regions".to_string() - )) - ); - } - - #[test] - fn restrict_inside_when_do_body() { - // The EU-sovereignty shape: gate at the composition layer, - // restrict in the `do:` body. - let yaml = r#" -when: session.labels contains 'eu_resident' -do: - - restrict: { allow_regions: [eu] } -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule"); - }; - match rule.effects.as_slice() { - [Effect::Restrict { spec }] => { - assert_eq!(spec.allow_regions, lit(&["eu"])); - }, - other => panic!("expected single Restrict effect, got {:?}", other), - } - } - - #[test] - fn restrict_inside_pdp_on_allow() { - // `restrict` composes in a PDP reaction — authz says yes, then - // pin routing. - let yaml = r#" -cedar: - action: read - resource: eu_data - on_allow: - - restrict: { allow_regions: [eu] } -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Pdp { on_allow, .. } = step else { - panic!("expected Step::Pdp, got {:?}", step); - }; - match on_allow.as_slice() { - [Step::Restrict { spec }] => { - assert_eq!(spec.allow_regions, lit(&["eu"])); - }, - other => panic!("expected Restrict in on_allow, got {:?}", other), - } - } - - #[test] - fn restrict_empty_body_rejected() { - // A `restrict:` with no constraint fields restricts nothing — - // author error. - let err = parse_step_yaml("restrict: {}").unwrap_err(); - assert!( - format!("{}", err).contains("no constraint fields"), - "got: {}", - err - ); - } - - #[test] - fn restrict_only_on_empty_rejected() { - // `on_empty` alone still constrains nothing. - let err = parse_step_yaml("restrict: { on_empty: deny }").unwrap_err(); - assert!( - format!("{}", err).contains("no constraint fields"), - "got: {}", - err - ); - } - - #[test] - fn restrict_unknown_field_rejected() { - let err = parse_step_yaml("restrict: { allow_zones: [eu] }").unwrap_err(); - let msg = format!("{}", err); - assert!(msg.contains("unknown"), "got: {}", msg); - assert!(msg.contains("allow_zones"), "got: {}", msg); - } - - #[test] - fn restrict_bad_on_empty_value_rejected() { - let err = parse_step_yaml("restrict: { deny_models: [\"openai/*\"], on_empty: maybe }") - .unwrap_err(); - let msg = format!("{}", err); - assert!(msg.contains("on_empty"), "got: {}", msg); - assert!(msg.contains("maybe"), "got: {}", msg); - } - - #[test] - fn restrict_non_scalar_custom_value_rejected() { - let yaml = r#" -restrict: - custom: - gpu: [h100, a100] -"#; - let err = parse_step_yaml(yaml).unwrap_err(); - assert!(format!("{}", err).contains("scalar"), "got: {}", err); - } - - #[test] - fn restrict_allowed_inside_parallel() { - // `restrict` is non-mutating, so it is *allowed* in parallel — - // this guards that we didn't accidentally class it as a mutation. - let yaml = r#" -parallel: - - "plugin(audit)" - - restrict: { allow_regions: [eu] } -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule"); - }; - match rule.effects.as_slice() { - [Effect::Parallel(inner)] => { - assert_eq!(inner.len(), 2); - assert!(matches!(inner[1], Effect::Restrict { .. })); - }, - other => panic!("expected Parallel with Restrict, got {:?}", other), - } - } - - #[test] - fn nested_orchestration() { - // `sequential: [plugin, parallel: [plugin, plugin]]` — the - // parser handles arbitrary nesting through parse_effect_value. - let yaml = r#" -sequential: - - "plugin(rate_limiter)" - - parallel: - - "plugin(pii_scanner)" - - "plugin(nemo)" -"#; - let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { - panic!("expected Rule") - }; - let Effect::Sequential(outer) = &rule.effects[0] else { - panic!("expected Sequential"); - }; - assert_eq!(outer.len(), 2); - assert!(matches!(outer[0], Effect::Plugin { .. })); - match &outer[1] { - Effect::Parallel(inner) => assert_eq!(inner.len(), 2), - other => panic!("expected nested Parallel, got {:?}", other), - } - } - - #[test] - fn split_respects_quotes_and_parens() { - // The `:` inside parens / quotes shouldn't be the separator. - let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap(); - assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }])); - if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { - assert_eq!(value, Literal::String("a:b".into())); - } else { - panic!("expected Comparison"); - } - } - - #[test] - fn compile_simple_route() { - let yaml = r#" -routes: - get_compensation: - pre_invocation: - - "require(authenticated)" - - "require(role.hr | role.finance)" - - "delegation.depth > 2 & include_ssn: deny" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("get_compensation").expect("route missing"); - assert_eq!(route.policy.len(), 3); - assert!(route - .declared_phases() - .contains(crate::rules::Phase::Policy)); - } - - #[test] - fn authorization_nested_and_flat_forms_are_equivalent() { - // The nested `authorization:` block and the flat - // `pre_invocation:` / `post_invocation:` forms must compile to - // the same route. - let nested = r#" -routes: - r: - authorization: - pre_invocation: - - "require(authenticated)" - post_invocation: - - "taint(audit, session)" -"#; - let flat = r#" -routes: - r: - pre_invocation: - - "require(authenticated)" - post_invocation: - - "taint(audit, session)" -"#; - let a = compile_config(nested).unwrap().routes; - let b = compile_config(flat).unwrap().routes; - let ra = a.get("r").expect("nested route"); - let rb = b.get("r").expect("flat route"); - assert_eq!(ra.policy.len(), rb.policy.len()); - assert_eq!(ra.post_policy.len(), rb.post_policy.len()); - assert_eq!(ra.policy.len(), 1); - assert_eq!(ra.post_policy.len(), 1); - } - - #[test] - fn legacy_key_nested_under_authorization_is_rejected() { - // Fail-closed: a legacy key nested inside the new `authorization:` - // wrapper must error, not be silently dropped (which would load a - // route with no authorization enforced). Guarded by - // `deny_unknown_fields` on `AuthorizationYaml`. - let yaml = r#" -routes: - r: - authorization: - policy: - - "require(authenticated)" -"#; - let err = compile_config(yaml).expect_err("nested legacy `policy:` must be rejected"); - let msg = format!("{err}"); - assert!( - msg.contains("policy") || msg.contains("unknown field"), - "error should flag the unknown/legacy nested key: {msg}" - ); - } - - #[test] - fn authorization_typo_under_wrapper_is_rejected() { - // `deny_unknown_fields` also catches typos so they don't silently - // no-op the phase. - let yaml = r#" -routes: - r: - authorization: - pre_invocaton: - - "require(authenticated)" -"#; - assert!( - compile_config(yaml).is_err(), - "a typo'd sub-key under `authorization:` must be rejected, not ignored" - ); - } - - #[test] - fn same_phase_declared_nested_and_flat_is_rejected() { - // The two forms are alternatives, not additive: declaring a phase - // both nested and flat on one section would run its effects twice. - let yaml = r#" -routes: - r: - authorization: - pre_invocation: - - "require(authenticated)" - pre_invocation: - - "require(role.hr)" -"#; - let err = compile_config(yaml).expect_err("both-forms-same-section must be rejected"); - assert!( - matches!(err, ParseError::ConflictingAuthorizationForms { ref phase, .. } if phase == "pre_invocation"), - "expected ConflictingAuthorizationForms for pre_invocation, got {err:?}" - ); - } - - #[test] - fn field_pipeline_error_names_field_path() { - // A malformed pipeline under `result:` names `result.` in - // the diagnostic so the operator can locate the offending field. - let yaml = r#" -routes: - r: - result: - x: "nonsense" -"#; - let err = compile_config(yaml).unwrap_err(); - let msg = format!("{err}"); - assert!(msg.contains("result.x"), "expected result.x in: {msg}"); - } - - #[test] - fn legacy_policy_field_names_are_rejected() { - // Breaking rename: the old authorization-phase keys must fail - // loudly, never be silently dropped (which would fail open). - for (old, hint) in [ - ("policy", "pre_invocation"), - ("post_policy", "post_invocation"), - ] { - let yaml = format!("routes:\n r:\n {old}:\n - \"require(authenticated)\"\n"); - let err = compile_config(&yaml).expect_err(&format!("legacy `{old}` must be rejected")); - let msg = format!("{err}"); - assert!( - msg.contains(old) && msg.contains(hint), - "`{old}` rejection should name the replacement `{hint}`: {msg}" - ); - } - } - - #[test] - fn legacy_only_route_is_not_silently_omitted() { - // A route whose *only* APL-ish key is a legacy name would look - // empty to the has-APL gate and be dropped — a fail-open. It must - // error instead. - let yaml = r#" -routes: - ghost: - policy: - - "require(authenticated)" -"#; - assert!( - matches!(compile_config(yaml), Err(ParseError::RenamedField { .. })), - "legacy-only route must be rejected, not omitted" - ); - } - - #[test] - fn compile_omits_routes_without_apl_blocks() { - // A route with no APL blocks (no authorization / pre_invocation / - // post_invocation / args / result) is a "legacy" route per - // apl-design and must be - // omitted from the compiled output. Unknown route keys (e.g. - // legacy CPEX `priority`) are stashed in `other`, not errored. - let yaml = r#" -routes: - legacy: - priority: 50 - apl_route: - pre_invocation: - - "require(authenticated)" -"#; - let routes = compile_config(yaml).unwrap().routes; - assert!(routes.contains_key("apl_route")); - assert!( - !routes.contains_key("legacy"), - "legacy route should be omitted, not compiled" - ); - } - - #[test] - fn compile_unknown_top_level_keys_ignored() { - let yaml = r#" -version: "0.1" -policy_evaluator: - kind: apl -plugins: - - name: rate_limiter - kind: native -imports: - - "./shared.yaml" -routes: - ping: - pre_invocation: - - "require(authenticated)" -"#; - let routes = compile_config(yaml).unwrap().routes; - assert!(routes.contains_key("ping")); - } - - #[test] - fn compile_propagates_rule_errors_with_source() { - let yaml = r#" -routes: - bad: - pre_invocation: - - "subject.id == garbage_ident" -"#; - let err = compile_config(yaml).unwrap_err(); - // RHS-as-identifier is rejected; the error mentions the offending input. - let msg = format!("{}", err); - assert!( - msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"), - "error message should reference the failure: {}", - msg, - ); - } - - #[test] - fn compile_plugin_step_string_form() { - let yaml = r#" -routes: - rate_limited: - pre_invocation: - - "plugin(rate_limiter)" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("rate_limited").unwrap(); - assert_eq!(route.policy.len(), 1); - match &route.policy[0] { - Effect::Plugin { name } => assert_eq!(name, "rate_limiter"), - other => panic!("expected Effect::Plugin, got {:?}", other), - } - } - - #[test] - fn compile_run_step_string_form_aliases_plugin() { - // `run(name)` is an alias for `plugin(name)`: both invoke a named - // plugin and compile to Effect::Plugin. - let yaml = r#" -routes: - rate_limited: - pre_invocation: - - "run(rate_limiter)" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("rate_limited").unwrap(); - assert_eq!(route.policy.len(), 1); - match &route.policy[0] { - Effect::Plugin { name } => assert_eq!(name, "rate_limiter"), - other => panic!("expected Effect::Plugin, got {:?}", other), - } - } - - #[test] - fn parse_step_run_is_plugin_alias() { - for s in ["run(audit-log)", "plugin(audit-log)"] { - let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap(); - match step { - crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"), - other => panic!("expected Step::Plugin for `{s}`, got {other:?}"), - } - } - // Empty / malformed `run(...)` surfaces a clear, verb-named error. - let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err(); - assert!( - format!("{err}").contains("run("), - "error should name `run(...)`: {err}" - ); - } - - #[test] - fn compile_taint_step_string_form() { - let yaml = r#" -routes: - audit_marked: - pre_invocation: - - "taint(audit, session)" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("audit_marked").unwrap(); - match &route.policy[0] { - Effect::Taint { label, scopes } => { - assert_eq!(label, "audit"); - assert_eq!(scopes, &vec![TaintScope::Session]); - }, - other => panic!("expected Effect::Taint, got {:?}", other), - } - } - - #[test] - fn compile_pdp_call_cedar_map_form() { - // Cedar uses the `cedar:` key with args inline + on_deny/on_allow. - let yaml = r#" -routes: - authz_check: - pre_invocation: - - cedar: - action: read - resource: employee - on_deny: - - deny - on_allow: - - "plugin(audit_logger)" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("authz_check").unwrap(); - match &route.policy[0] { - Effect::Pdp { - call, - on_deny, - on_allow, - } => { - assert_eq!(call.dialect, PdpDialect::Cedar); - // Cedar args are a map: action + resource (with reaction - // keys stripped out). - let args_map = call.args.as_mapping().expect("cedar args should be a map"); - assert!(args_map.contains_key(serde_yaml::Value::String("action".into()))); - assert!(args_map.contains_key(serde_yaml::Value::String("resource".into()))); - assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); - assert_eq!(on_deny.len(), 1); - assert_eq!(on_allow.len(), 1); - }, - other => panic!("expected Effect::Pdp, got {:?}", other), - } - } - - #[test] - fn compile_pdp_call_cel_map_form() { - // `cel:` carries an `expr:` string + optional on_deny/on_allow - // reactions. Routes to the CEL-backed resolver via PdpDialect::Cel. - let yaml = r#" -routes: - authz_check: - pre_invocation: - - cel: - expr: "subject.id == 'alice' && delegation.depth <= 2" - on_deny: - - deny -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("authz_check").unwrap(); - match &route.policy[0] { - Effect::Pdp { - call, - on_deny, - on_allow, - } => { - assert_eq!(call.dialect, PdpDialect::Cel); - let args_map = call.args.as_mapping().expect("cel args should be a map"); - assert!(args_map.contains_key(serde_yaml::Value::String("expr".into()))); - // Reaction keys are stripped from the opaque call args. - assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); - assert_eq!(on_deny.len(), 1); - assert_eq!(on_allow.len(), 0); - }, - other => panic!("expected Effect::Pdp, got {:?}", other), - } - } - - #[test] - fn compile_pdp_call_opa_paren_form() { - // OPA uses `opa("path"):` with the path inside parens + body is reactions. - let yaml = r#" -routes: - opa_check: - pre_invocation: - - 'opa("hr/compensation/deny"):': - on_deny: - - deny -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("opa_check").unwrap(); - match &route.policy[0] { - Effect::Pdp { call, on_deny, .. } => { - assert_eq!(call.dialect, PdpDialect::Opa); - // OPA args are a string (the path). - assert!(call.args.as_str().unwrap().contains("hr/compensation/deny")); - assert_eq!(on_deny.len(), 1); - }, - other => panic!("expected Effect::Pdp, got {:?}", other), - } - } - - #[test] - fn compile_pdp_unknown_dialect_becomes_custom() { - let yaml = r#" -routes: - custom_pdp: - pre_invocation: - - my_engine: - on_deny: [deny] -"#; - let routes = compile_config(yaml).unwrap().routes; - match &routes.get("custom_pdp").unwrap().policy[0] { - Effect::Pdp { call, .. } => { - assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into())); - }, - other => panic!("expected Pdp, got {:?}", other), - } - } - - #[tokio::test] - async fn end_to_end_hr_compensation() { - let yaml = r#" -routes: - get_compensation: - pre_invocation: - - "require(authenticated)" - - "require(role.hr | role.finance)" - - "delegation.depth > 2: deny" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("get_compensation").unwrap(); - - let pdp: std::sync::Arc = std::sync::Arc::new(NullPdpResolver); - let plugins: std::sync::Arc = - std::sync::Arc::new(NullPluginInvoker); - let delegations: std::sync::Arc = - std::sync::Arc::new(crate::NoopDelegationInvoker); - let elicitations: std::sync::Arc = - std::sync::Arc::new(crate::NoopElicitationInvoker); - - // Alice: authenticated, hr role, depth=1 → allow. - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("role.hr", true); - bag.set("delegation.depth", 1_i64); - assert_eq!( - crate::evaluate_effects( - &route.policy, - &mut bag, - &pdp, - &plugins, - &delegations, - &elicitations, - crate::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null) - ) - .await - .decision, - Decision::Allow, - ); - - // Same Alice but depth=3 → deny (third rule fires). - bag.set("delegation.depth", 3_i64); - match crate::evaluate_effects( - &route.policy, - &mut bag, - &pdp, - &plugins, - &delegations, - &elicitations, - crate::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("pre_invocation[2]"), - "expected pre_invocation[2], got {}", - rule_source - ); - }, - d => panic!("expected Deny, got {:?}", d), - } - - // Bob: authenticated but neither hr nor finance → deny on rule 1. - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("delegation.depth", 1_i64); - match crate::evaluate_effects( - &route.policy, - &mut bag, - &pdp, - &plugins, - &delegations, - &elicitations, - crate::DispatchPhase::Pre, - &mut crate::route::RoutePayload::new(serde_json::Value::Null), - ) - .await - .decision - { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("pre_invocation[1]"), - "expected pre_invocation[1], got {}", - rule_source - ); - }, - d => panic!("expected Deny, got {:?}", d), - } - } - - // Test fixtures for async evaluator — null resolvers that nothing in - // a pure-rule route should ever invoke. - struct NullPdpResolver; - #[async_trait::async_trait] - impl crate::PdpResolver for NullPdpResolver { - fn dialect(&self) -> crate::PdpDialect { - crate::PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &crate::PdpCall, - _bag: &crate::AttributeBag, - ) -> Result { - panic!("NullPdpResolver should not be invoked in pure-rule tests"); - } - } - - struct NullPluginInvoker; - #[async_trait::async_trait] - impl crate::PluginInvoker for NullPluginInvoker { - async fn invoke( - &self, - _name: &str, - _bag: &crate::AttributeBag, - _invocation: crate::PluginInvocation<'_>, - ) -> Result { - panic!("NullPluginInvoker should not be invoked in pure-rule tests"); - } - } - - #[test] - fn pipeline_simple_bare_stages() { - let p = parse_pipeline("str").unwrap(); - assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]); - - let p = parse_pipeline("omit").unwrap(); - assert_eq!(p.stages, vec![Stage::Omit]); - - let p = parse_pipeline("hash").unwrap(); - assert_eq!(p.stages, vec![Stage::Hash]); - } - - #[test] - fn pipeline_chains_split_on_pipe() { - let p = parse_pipeline("str | mask(4)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },] - ); - - let p = parse_pipeline("int | 0..1M").unwrap(); - assert_eq!( - p.stages, - vec![ - Stage::Type(TypeCheck::Int), - Stage::Range { - min: Some(0), - max: Some(1_000_000) - }, - ] - ); - } - - #[test] - fn pipeline_pipe_inside_parens_does_not_split() { - // `redact(!a | b)` is one stage; the inner `|` is OR inside a - // predicate condition, not a chain separator. - let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap(); - assert_eq!(p.stages.len(), 2); - match &p.stages[1] { - Stage::Redact { condition: Some(_) } => {}, - other => panic!("expected Redact with condition, got {:?}", other), - } - } - - #[test] - fn pipeline_length_constraints() { - let p = parse_pipeline("len(..500)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Length { - min: None, - max: Some(500) - }] - ); - let p = parse_pipeline("len(10..50)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Length { - min: Some(10), - max: Some(50) - }] - ); - let p = parse_pipeline("len(8..)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Length { - min: Some(8), - max: None - }] - ); - } - - #[test] - fn pipeline_range_with_suffixes() { - let p = parse_pipeline("0..10k").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Range { - min: Some(0), - max: Some(10_000) - }] - ); - let p = parse_pipeline("0..1M").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Range { - min: Some(0), - max: Some(1_000_000) - }] - ); - let p = parse_pipeline("..500").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Range { - min: None, - max: Some(500) - }] - ); - } - - #[test] - fn pipeline_enum_unquoted_and_quoted() { - let p = parse_pipeline("enum(low, medium, high)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Enum { - values: vec!["low".into(), "medium".into(), "high".into()], - }] - ); - let p = parse_pipeline(r#"enum("a", "b")"#).unwrap(); - assert_eq!( - p.stages, - vec![Stage::Enum { - values: vec!["a".into(), "b".into()], - }] - ); - } - - #[test] - fn pipeline_redact_with_predicate_condition() { - let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap(); - assert_eq!(p.stages.len(), 2); - match &p.stages[1] { - Stage::Redact { - condition: Some(Expression::Not(inner)), - } => match inner.as_ref() { - Expression::Condition(Condition::IsTrue { key }) => { - assert_eq!(key, "perm.view_ssn"); - }, - other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other), - }, - other => panic!("expected Redact with Not condition, got {:?}", other), - } - } - - #[test] - fn pipeline_taint_scopes() { - let p = parse_pipeline("taint(PII)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Taint { - label: "PII".into(), - scopes: vec![TaintScope::Session], - }] - ); - let p = parse_pipeline("taint(PII, message)").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Taint { - label: "PII".into(), - scopes: vec![TaintScope::Message], - }] - ); - let p = parse_pipeline("taint(PII, [session, message])").unwrap(); - assert_eq!( - p.stages, - vec![Stage::Taint { - label: "PII".into(), - scopes: vec![TaintScope::Session, TaintScope::Message], - }] - ); - } - - #[test] - fn pipeline_unknown_stage_rejected() { - let err = parse_pipeline("nonsense").unwrap_err(); - assert!(format!("{}", err).contains("unknown stage")); - } - - #[test] - fn pipeline_omit_with_args_rejected() { - // omit has no conditional form. - let err = parse_pipeline("omit(!perm.x)").unwrap_err(); - assert!(format!("{}", err).contains("omit takes no arguments")); - } - - #[test] - fn compile_route_with_args_and_result() { - let yaml = r#" -routes: - get_compensation: - args: - employee_id: "uuid" - amount: "int | 0..1M" - result: - ssn: "str | redact(!perm.view_ssn)" - employee_id: "str | mask(4)" - internal_notes: "omit" -"#; - let routes = compile_config(yaml).unwrap().routes; - let route = routes.get("get_compensation").expect("missing route"); - assert_eq!(route.args.len(), 2); - assert_eq!(route.result.len(), 3); - - // Pull out the ssn pipeline and confirm shape. - let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap(); - assert_eq!(ssn.pipeline.stages.len(), 2); - assert!(matches!( - ssn.pipeline.stages[0], - Stage::Type(TypeCheck::Str) - )); - assert!(matches!( - ssn.pipeline.stages[1], - Stage::Redact { condition: Some(_) } - )); - - // declared_phases should include Result and Args now. - let phases = route.declared_phases(); - assert!(phases.contains(crate::rules::Phase::Args)); - assert!(phases.contains(crate::rules::Phase::Result)); - } - - #[test] - fn compile_route_with_only_args_still_compiles() { - // A route with no authorization block but with `args:` - // validators is still an APL route (declared_phases non-empty). - let yaml = r#" -routes: - validate_only: - args: - employee_id: "uuid" -"#; - let routes = compile_config(yaml).unwrap().routes; - assert!(routes.contains_key("validate_only")); - } - - #[test] - fn compile_propagates_pipeline_parse_errors() { - let yaml = r#" -routes: - bad: - result: - x: "nonsense" -"#; - let err = compile_config(yaml).unwrap_err(); - assert!(format!("{}", err).contains("unknown stage")); - } - - #[test] - fn compile_captures_root_plugins_block_into_registry() { - let yaml = r#" -plugins: - - name: rate_limiter - kind: native - hooks: [tool_pre_invoke] - capabilities: [read_subject] - config: - max_requests: 100 - - name: audit - kind: native - hooks: [tool_post_invoke] -routes: - get_compensation: - pre_invocation: - - "plugin(rate_limiter)" -"#; - let cfg = compile_config(yaml).unwrap(); - assert_eq!(cfg.plugins.len(), 2); - let rl = cfg.plugins.get("rate_limiter").unwrap(); - assert_eq!(rl.kind, "native"); - assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]); - assert_eq!(rl.capabilities, vec!["read_subject".to_string()]); - // The route should still compile (uses plugin(rate_limiter)). - assert!(cfg.routes.contains_key("get_compensation")); - } - - #[test] - fn compile_captures_route_level_plugin_overrides() { - let yaml = r#" -plugins: - - name: rate_limiter - kind: native - hooks: [tool_pre_invoke] - config: - max_requests: 100 -routes: - hot_path: - pre_invocation: - - "plugin(rate_limiter)" - plugins: - rate_limiter: - config: - max_requests: 10 - on_error: ignore -"#; - let cfg = compile_config(yaml).unwrap(); - let route = cfg.routes.get("hot_path").unwrap(); - let ovr = route.plugin_overrides.get("rate_limiter").unwrap(); - assert_eq!(ovr.on_error.as_deref(), Some("ignore")); - let cfg_yaml = ovr.config.as_ref().unwrap(); - assert_eq!( - cfg_yaml["max_requests"], - serde_yaml::from_str::("10").unwrap() - ); - - // Verify EffectivePlugin::resolve sees the override. - let eff = crate::plugin_decl::EffectivePlugin::resolve( - "rate_limiter", - &cfg.plugins, - &route.plugin_overrides, - ) - .unwrap(); - assert_eq!(eff.on_error, Some("ignore")); - // Hooks NOT overridable — still from the global declaration. - assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); - } - - #[test] - fn compile_policy_block_value_parses_apl_body() { - let yaml = r#" -pre_invocation: - - "require(authenticated)" -result: - ssn: "redact(!perm.view_ssn)" -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let compiled = - compile_policy_block_value("global.policy.all", &value).expect("compile block"); - assert_eq!(compiled.route_key, "global.policy.all"); - assert_eq!(compiled.policy.len(), 1); - assert_eq!(compiled.result.len(), 1); - assert_eq!(compiled.result[0].field, "ssn"); - } - - #[test] - fn compile_policy_block_value_null_is_empty_route() { - let value = serde_yaml::Value::Null; - let compiled = - compile_policy_block_value("global.defaults.tool", &value).expect("compile null"); - assert!(compiled.declared_phases().is_empty()); - assert_eq!(compiled.route_key, "global.defaults.tool"); - } - - #[test] - fn compile_policy_block_value_threads_source_into_rule_paths() { - let yaml = r#" -pre_invocation: - - "require(authenticated)" -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile"); - match &compiled.policy[0] { - crate::rules::Effect::When { source, .. } => { - assert_eq!(source, "global.policies.hr.pre_invocation[0]"); - }, - other => panic!("expected When, got {:?}", other), - } - } - - #[test] - fn parse_delegate_step_with_only_plugin() { - let yaml = r#" -- delegate: - plugin: workday-oauth -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate, got {step:?}"); - }; - assert_eq!(ds.plugin_name, "workday-oauth"); - assert!(ds.config_override.is_none()); - assert!(ds.on_error.is_none()); - assert_eq!(ds.source, "test.policy[0]"); - } - - #[test] - fn parse_delegate_step_with_config_and_on_error() { - let yaml = r#" -- delegate: - plugin: workday-oauth - config: - target: workday-api - permissions: [read_compensation] - on_error: deny -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[1]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate, got {step:?}"); - }; - assert_eq!(ds.plugin_name, "workday-oauth"); - assert_eq!(ds.on_error.as_deref(), Some("deny")); - let cfg = ds.config_override.as_ref().expect("config_override set"); - let target = cfg - .as_mapping() - .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) - .and_then(|v| v.as_str()); - assert_eq!(target, Some("workday-api")); - } - - #[test] - fn parse_delegate_step_missing_plugin_errors() { - let yaml = r#" -- delegate: - config: { target: workday-api } -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin"); - let msg = format!("{err}"); - assert!(msg.contains("requires `plugin:"), "got: {msg}"); - } - - #[test] - fn parse_delegate_step_empty_plugin_errors() { - let yaml = r#" -- delegate: - plugin: "" -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin"); - let msg = format!("{err}"); - assert!(msg.contains("cannot be empty"), "got: {msg}"); - } - - #[test] - fn parse_delegate_step_non_string_on_error_errors() { - let yaml = r#" -- delegate: - plugin: workday-oauth - on_error: 42 -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error"); - let msg = format!("{err}"); - assert!(msg.contains("on_error"), "got: {msg}"); - } - - #[test] - fn parse_delegate_step_non_map_body_errors() { - let yaml = r#" -- delegate: workday-oauth -"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body"); - let msg = format!("{err}"); - assert!(msg.contains("must be a map"), "got: {msg}"); - } - - #[test] - fn parse_delegate_string_bare_plugin_name() { - let yaml = r#"- "delegate(workday-oauth)""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate, got {step:?}"); - }; - assert_eq!(ds.plugin_name, "workday-oauth"); - assert!(ds.config_override.is_none()); - assert!(ds.on_error.is_none()); - assert_eq!(ds.source, "test.policy[0]"); - } - - #[test] - fn parse_delegate_string_with_string_kwargs() { - let yaml = - r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate, got {step:?}"); - }; - assert_eq!(ds.plugin_name, "workday-oauth"); - let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); - assert_eq!( - cfg.get(serde_yaml::Value::String("target".into())) - .and_then(|v| v.as_str()), - Some("workday-api"), - ); - assert_eq!( - cfg.get(serde_yaml::Value::String("audience".into())) - .and_then(|v| v.as_str()), - Some("https://workday.com"), - ); - } - - #[test] - fn parse_delegate_string_with_list_kwarg() { - let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate"); - }; - let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); - let perms = cfg - .get(serde_yaml::Value::String("permissions".into())) - .and_then(|v| v.as_sequence()) - .expect("permissions sequence"); - let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(names, vec!["read_compensation", "write_notes"]); - } - - #[test] - fn parse_delegate_string_on_error_pulled_out() { - let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate"); - }; - assert_eq!(ds.on_error.as_deref(), Some("continue")); - // on_error must NOT also leak into config_override. - let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); - assert!( - cfg.get(serde_yaml::Value::String("on_error".into())) - .is_none(), - "on_error must not appear in config_override" - ); - } - - #[test] - fn parse_delegate_string_quoted_plugin_name() { - // Quoting the plugin name is harmless — the parser strips - // the wrapping quotes. Useful when the name contains - // characters the bare-ident reader doesn't like. - let yaml = r#"- 'delegate("workday-oauth")'"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate"); - }; - assert_eq!(ds.plugin_name, "workday-oauth"); - } - - #[test] - fn parse_delegate_string_quoted_value_preserves_internal_commas() { - let yaml = - r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let step = parse_step(entry, "test.policy[0]").expect("parse"); - let crate::step::Step::Delegate(ds) = step else { - panic!("expected Delegate"); - }; - let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); - assert_eq!( - cfg.get(serde_yaml::Value::String("audience".into())) - .and_then(|v| v.as_str()), - Some("https://workday.com,backup.workday.com"), - ); - } - - #[test] - fn parse_delegate_string_empty_args_errors() { - let yaml = r#"- "delegate()""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("empty args"); - let msg = format!("{err}"); - assert!(msg.contains("plugin name"), "got: {msg}"); - } - - #[test] - fn parse_delegate_string_plugin_kwarg_rejected() { - // `plugin:` as a kwarg is ambiguous when the plugin name is - // also the positional first arg — reject loudly. - let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg"); - let msg = format!("{err}"); - assert!(msg.contains("positional"), "got: {msg}"); - } - - #[test] - fn parse_delegate_string_kwarg_missing_colon_errors() { - let yaml = r#"- "delegate(workday-oauth, target workday-api)""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("missing colon"); - let msg = format!("{err}"); - assert!(msg.contains("key: value"), "got: {msg}"); - } - - #[test] - fn parse_delegate_string_unbalanced_brackets_errors() { - let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#; - let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let entry = &value.as_sequence().unwrap()[0]; - let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced"); - let msg = format!("{err}"); - assert!( - msg.contains("unmatched") || msg.contains("unbalanced"), - "got: {msg}" - ); - } - - #[test] - fn compile_route_mixed_string_and_map_delegate_forms() { - // Both forms coexist in the same policy block — string form - // for the compact case, map form for richer config. - let yaml = r#" -routes: - get_compensation: - pre_invocation: - - "require(role.hr)" - - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - - delegate: - plugin: audit-receipt - on_error: continue - config: - mode: trace -"#; - let cfg = compile_config(yaml).expect("compile"); - let route = cfg.routes.get("get_compensation").expect("route"); - assert_eq!(route.policy.len(), 3); - - // Step [1] is the string-form delegate. - let crate::rules::Effect::Delegate(s1) = &route.policy[1] else { - panic!("expected Delegate at policy[1]"); - }; - assert_eq!(s1.plugin_name, "workday-oauth"); - assert!(s1.on_error.is_none()); - - // Step [2] is the map-form delegate. - let crate::rules::Effect::Delegate(s2) = &route.policy[2] else { - panic!("expected Delegate at policy[2]"); - }; - assert_eq!(s2.plugin_name, "audit-receipt"); - assert_eq!(s2.on_error.as_deref(), Some("continue")); - } - - #[test] - fn compile_route_with_delegate_in_policy_and_post_policy() { - // End-to-end: delegate() lands in the right phase with the - // right source path for diagnostics. Mixed with normal rules - // to prove it doesn't perturb existing step parsing. - let yaml = r#" -routes: - get_compensation: - pre_invocation: - - "require(role.hr)" - - delegate: - plugin: workday-oauth - config: - target: workday-api - permissions: [read_compensation] - - "require(authenticated)" - post_invocation: - - delegate: - plugin: audit-biscuit - on_error: continue -"#; - let cfg = compile_config(yaml).expect("compile"); - let route = cfg.routes.get("get_compensation").expect("route present"); - assert_eq!(route.policy.len(), 3); - - // Policy step [1] is the delegate. - let crate::rules::Effect::Delegate(ds) = &route.policy[1] else { - panic!("expected Delegate at policy[1], got {:?}", route.policy[1]); - }; - assert_eq!(ds.plugin_name, "workday-oauth"); - assert_eq!(ds.source, "get_compensation.pre_invocation[1]"); - - // post_policy[0] is the audit-biscuit delegate. - let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else { - panic!("expected Delegate at post_policy[0]"); - }; - assert_eq!(post_ds.plugin_name, "audit-biscuit"); - assert_eq!(post_ds.on_error.as_deref(), Some("continue")); - assert_eq!(post_ds.source, "get_compensation.post_invocation[0]"); - } - - fn parse_elicit_str(s: &str) -> crate::step::ElicitStep { - let value = serde_yaml::Value::String(s.to_string()); - let step = parse_step(&value, "test.policy[0]").expect("parse"); - match step { - crate::step::Step::Elicit(e) => e, - other => panic!("expected Elicit, got {other:?}"), - } - } - - #[test] - fn parse_require_approval_full_kwargs() { - let e = parse_elicit_str( - "require_approval(manager-approver, from: user.manager, channel: \"ciba\", \ - scope: \"args.amount <= 25000\", \ - purpose: \"Approve salary change\", timeout: 24h)", - ); - assert_eq!(e.kind, ElicitKind::Approval); - assert_eq!(e.plugin_name, "manager-approver"); - assert_eq!(e.from, "user.manager"); - assert_eq!(e.channel.as_deref(), Some("ciba")); - assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000")); - assert_eq!(e.purpose.as_deref(), Some("Approve salary change")); - assert_eq!(e.timeout.as_deref(), Some("24h")); - assert!(e.on_error.is_none()); - assert!(e.config_override.is_none()); - assert_eq!(e.source, "test.policy[0]"); - } - - #[test] - fn parse_channel_is_optional() { - // No `channel:` — it's an audit label, not required for routing. - let e = parse_elicit_str("require_approval(manager-approver, from: user.manager)"); - assert_eq!(e.plugin_name, "manager-approver"); - assert!(e.channel.is_none()); - } - - #[test] - fn parse_each_verb_maps_to_its_kind() { - for (verb, want) in [ - ("require_approval", ElicitKind::Approval), - ("confirm", ElicitKind::Confirm), - ("require_step_up", ElicitKind::StepUp), - ("require_attestation", ElicitKind::Attestation), - ("request_info", ElicitKind::Info), - ("require_review", ElicitKind::Review), - ] { - let e = parse_elicit_str(&format!("{verb}(inband-asker, from: user.sub)")); - assert_eq!(e.kind, want, "verb `{verb}`"); - assert_eq!(e.plugin_name, "inband-asker"); - assert_eq!(e.from, "user.sub"); - } - } - - #[test] - fn parse_confirm_prompt_aliases_purpose() { - // The elicitation-hook doc uses `prompt` for confirm; it maps to - // the same `purpose` field as `require_approval`. - let e = - parse_elicit_str("confirm(inband-asker, from: user.sub, prompt: \"Drop the table?\")"); - assert_eq!(e.kind, ElicitKind::Confirm); - assert_eq!(e.purpose.as_deref(), Some("Drop the table?")); - } - - #[test] - fn parse_unknown_kwarg_goes_to_config_override() { - let e = parse_elicit_str( - "require_approval(slack-approver, from: user.manager, \ - details_link: https://approvals.example.com/req)", - ); - let cfg = e.config_override.as_ref().unwrap().as_mapping().unwrap(); - assert_eq!( - cfg.get(serde_yaml::Value::String("details_link".into())) - .and_then(|v| v.as_str()), - Some("https://approvals.example.com/req"), - ); - // Recognized keys must NOT leak into config_override. - assert!(cfg.get(serde_yaml::Value::String("from".into())).is_none()); - } - - #[test] - fn parse_on_error_pulled_out() { - let e = parse_elicit_str( - "require_approval(manager-approver, from: user.manager, on_error: continue)", - ); - assert_eq!(e.on_error.as_deref(), Some("continue")); - assert!(e.config_override.is_none()); - } - - #[test] - fn parse_missing_plugin_name_errors() { - let value = serde_yaml::Value::String("require_approval(from: user.manager)".into()); - let err = parse_step(&value, "test.policy[0]").expect_err("missing plugin name"); - // `from: user.manager` parses as the positional first arg, so the - // missing piece surfaces as the required `from` kwarg. - assert!(format!("{err}").contains("requires `from`"), "got: {err}"); - } - - #[test] - fn parse_empty_args_errors() { - let value = serde_yaml::Value::String("require_approval()".into()); - let err = parse_step(&value, "test.policy[0]").expect_err("empty args"); - assert!(format!("{err}").contains("plugin name"), "got: {err}"); - } - - #[test] - fn parse_plugin_kwarg_rejected() { - // Passing `plugin:` as a kwarg is ambiguous with the positional. - let value = serde_yaml::Value::String( - "require_approval(manager-approver, plugin: other, from: user.manager)".into(), - ); - let err = parse_step(&value, "test.policy[0]").expect_err("plugin kwarg"); - assert!(format!("{err}").contains("first positional"), "got: {err}"); - } - - #[test] - fn parse_missing_from_errors() { - let value = serde_yaml::Value::String("require_approval(manager-approver)".into()); - let err = parse_step(&value, "test.policy[0]").expect_err("missing from"); - assert!(format!("{err}").contains("requires `from`")); - } - - #[test] - fn parse_require_prefixed_verbs_do_not_collide() { - // `require_attestation` must not be swallowed by a `require_a*` - // partial match — each verb is matched with its trailing `(`. - let e = parse_elicit_str("require_attestation(inband-asker, from: user.sub)"); - assert_eq!(e.kind, ElicitKind::Attestation); - } - - #[test] - fn compile_route_with_require_approval_in_policy() { - // End-to-end: the sugar verb survives compile_config and lands as - // an Effect::Elicit at the right phase/source. The `when`-gated - // form mirrors the manager-approval design doc. - let yaml = r#" -routes: - payroll_adjust: - pre_invocation: - - "require(authenticated)" - - when: "args.amount > 10000" - do: - - "require_approval(manager-approver, from: user.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve salary change\", timeout: 24h)" -"#; - let cfg = compile_config(yaml).expect("compile"); - let route = cfg.routes.get("payroll_adjust").expect("route present"); - assert_eq!(route.policy.len(), 2); - - // policy[1] is the `when` wrapper; its body[0] is the elicitation. - let crate::rules::Effect::When { body, .. } = &route.policy[1] else { - panic!("expected When at policy[1], got {:?}", route.policy[1]); - }; - let crate::rules::Effect::Elicit(e) = &body[0] else { - panic!("expected Elicit in when-body, got {:?}", body[0]); - }; - assert_eq!(e.kind, ElicitKind::Approval); - assert_eq!(e.plugin_name, "manager-approver"); - assert_eq!(e.from, "user.manager"); - assert_eq!(e.channel.as_deref(), Some("ciba")); - assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000")); - } - - #[test] - fn parse_pipeline_rejects_validate_stage_at_compile_time() { - // Named-validator dispatch isn't implemented; the parser - // rejects `validate(...)` rather than letting it through to - // a runtime stub that silently passes. Diagnostic points the - // operator at the working alternatives. - let err = parse_pipeline("str | validate(ssn_format) | mask(4)") - .expect_err("validate(name) should fail to parse"); - let msg = format!("{err}"); - assert!( - msg.contains("not implemented"), - "diagnostic should explain that validate is unimplemented: {msg}", - ); - assert!( - msg.contains("regex") && msg.contains("plugin"), - "diagnostic should suggest regex(...) and plugin(...): {msg}", - ); - assert!( - msg.contains("ssn_format"), - "diagnostic should echo the rejected validator name: {msg}", - ); - } - - #[test] - fn parse_pipeline_does_not_reject_other_stages() { - // Sanity: the validate rejection doesn't catch unrelated - // stages. A pipeline with no validate stage parses cleanly. - let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)") - .expect("non-validate pipeline parses"); - assert_eq!(p.stages.len(), 4); - } -} diff --git a/crates/apl-core/src/pipeline.rs b/crates/apl-core/src/pipeline.rs deleted file mode 100644 index 2a84b5e5..00000000 --- a/crates/apl-core/src/pipeline.rs +++ /dev/null @@ -1,158 +0,0 @@ -// Location: ./crates/apl-core/src/pipeline.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Pipe-chain IR for APL `args:` and `result:` phases. -// -// A field-level pipeline is a sequence of `Stage`s separated by `|` in the -// DSL. Validators (str/int/range/...) check the field's value and can fail -// the request; transforms (mask/redact/omit/hash) modify the value; effects -// (taint) record side information. -// -// Stages whose evaluator behavior is deferred (taint dispatch, -// plugin invocation, regex/named validators, scan placeholders) are still -// represented in the IR so the parser can produce them — the evaluator -// recognizes them and returns a clear "deferred" signal rather than crashing. - -use serde::{Deserialize, Serialize}; - -use crate::rules::Expression; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TypeCheck { - Str, - Int, - Bool, - Float, - Email, - Url, - Uuid, -} - -/// Scope at which a taint applies. Marked `#[non_exhaustive]` so new -/// variants (e.g. `Request`, `Pipeline`, conversation-level) can be -/// added without breaking downstream exhaustive matches. v0 emits only -/// `Session` and `Message`; plugin-extracted taints (from -/// `extensions.security.labels` diffs in `CmfPluginInvoker`) default to -/// `Session` because cpex-core's label monotonicity is session-semantic. -/// Config-side `Step::Taint`/`Stage::Taint` declares scopes explicitly. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TaintScope { - Session, - Message, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ScanKind { - PiiRedact, - PiiDetect, - InjectionScan, -} - -/// One stage in a pipe chain. -/// -/// Stages execute left-to-right against a single field value. Validators -/// halt the pipeline on failure; transforms produce a new value; effects -/// (taint) annotate without changing the value. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Stage { - Type(TypeCheck), - /// `regex("pattern")` — parser captures the pattern; evaluator stubbed - /// until we add the `regex` crate dependency. - Regex { - pattern: String, - }, - /// `validate(name)` — named validator dispatch; evaluator stubbed. - Validate { - name: String, - }, - /// `len(..N)`, `len(N..M)`, `len(N..)` — string length bounds. - Length { - min: Option, - max: Option, - }, - /// Bare range literal `N..M`, `..M`, `N..`, with optional `k`/`K`/`m`/`M` - /// numeric suffixes. Integer-only. - Range { - min: Option, - max: Option, - }, - /// `enum(a, b, c)` — value must equal one of the listed strings. - Enum { - values: Vec, - }, - - /// `mask(N)` — replace all but last N chars with `*`. - Mask { - keep_last: usize, - }, - /// `redact` (unconditional) or `redact(!condition)` (conditional). - /// Replaces value with `[REDACTED]` when condition is true (or always, - /// if no condition). - Redact { - condition: Option, - }, - /// `omit` — drop the field from output entirely. No conditional form - /// — use a policy rule for conditional omit. - Omit, - /// `hash` — replace value with a hash digest. - Hash, - - Taint { - label: String, - scopes: Vec, - }, - Plugin { - name: String, - }, - Scan { - kind: ScanKind, - }, -} - -/// Sequence of stages applied to one field's value. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct Pipeline { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub stages: Vec, -} - -impl Pipeline { - pub fn new() -> Self { - Self::default() - } - pub fn push(&mut self, stage: Stage) { - self.stages.push(stage); - } - pub fn is_empty(&self) -> bool { - self.stages.is_empty() - } -} - -/// Attaches a pipeline to a specific field name in the args or result phase. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FieldRule { - pub field: String, - pub pipeline: Pipeline, - /// Source location (e.g., `"get_compensation.result.ssn"`) for audit. - pub source: String, -} - -/// A taint label produced as a side effect of running a pipeline. -/// -/// The evaluator accumulates these in `PipelineEvaluation.taints`; the host -/// (apl-cpex) drains them and writes to the actual SessionStore. Same shape -/// as `Stage::Taint`'s fields, but lives at the evaluator boundary because -/// it also carries taints emitted by plugin invocations and scan stages -/// — not just literal `taint(...)` stages. -#[derive(Debug, Clone, PartialEq)] -pub struct TaintEvent { - pub label: String, - pub scopes: Vec, -} diff --git a/crates/apl-core/src/plugin_decl.rs b/crates/apl-core/src/plugin_decl.rs deleted file mode 100644 index 2ad0ab2f..00000000 --- a/crates/apl-core/src/plugin_decl.rs +++ /dev/null @@ -1,286 +0,0 @@ -// Location: ./crates/apl-core/src/plugin_decl.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Plugin declarations — the parsed shape of the `plugins:` block in a -// unified-config YAML document, plus the per-route override block and -// the 2-layer resolver that merges them. -// -// Layering: -// - Global declaration (root `plugins:`) — full shape -// - Route-level override (`routes..plugins.

:`) — `config`, -// `capabilities`, `on_error` only; hooks/kind/source NOT overridable -// - `EffectivePlugin::resolve(name, registry, route)` merges them. -// -// v0 enforcement: hooks are read from the resolved view (which equals -// the global view since hooks aren't overridable). Config + capability -// overrides are parsed and stored so they survive in the IR for later -// consumers, but not propagated to dispatch yet — capability gating -// and per-call config-override plumbing are tracked separately in the -// APL implementation memory. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// One entry from the root `plugins:` block. The minimal shape apl-core -/// needs to make routing + dispatch decisions; richer CPEX fields -/// (`source`, `priority`, `mode`, transport blocks, `description`, -/// `version`) are captured opaquely under `extra` so the round-trip -/// preserves them without us modeling every variant for v0. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginDeclaration { - /// Plugin name — referenced from routes by `plugin(name)` and used - /// as the key in [`PluginRegistry`]. - pub name: String, - - /// Implementation kind. Spec defines a closed set (`builtin`, - /// `native`, `wasm`, FQN, `external`, `isolated_venv`, PDP kinds) - /// but we parse as a free string so configs using future kinds - /// the runtime understands aren't rejected at the apl-core layer. - pub kind: String, - - /// CPEX hook names this plugin implements. Invokers pick which - /// hook to dispatch based on this list; v0 uses the first entry, - /// future versions will choose by invocation context (policy vs - /// post_policy vs pipe-chain). - /// - /// NOT overridable per-route. - #[serde(default)] - pub hooks: Vec, - - /// Attribute-extension capabilities (`read_subject`, `read_labels`, - /// `append_labels`, `read_headers`, …). The runtime uses these for - /// extension filtering before dispatch. v0: parsed but not yet - /// enforced (capability gating is a separately tracked item). - #[serde(default)] - pub capabilities: Vec, - - /// Opaque per-plugin config. Passed to the plugin verbatim by the - /// CPEX runtime; apl-core doesn't interpret it. - #[serde(default)] - pub config: Option, - - /// `fail | ignore | disable`. Defaults to `fail` per spec when None. - #[serde(default)] - pub on_error: Option, - - /// Catch-all for `source`, `priority`, `mode`, transport blocks, - /// `description`, `version`, etc. Preserved so a future loader can - /// read them without re-parsing the YAML. - #[serde(flatten)] - pub extra: HashMap, -} - -/// Per-route override block — only the spec-overridable keys. Bare -/// key-value pairs are NOT merged into `config` implicitly (spec line -/// 399): "The override object always uses the same keys as a plugin -/// declaration (`config:`, `capabilities:`, `on_error:`); bare -/// key-value pairs are not merged into `config` implicitly." -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginOverride { - #[serde(default)] - pub config: Option, - - #[serde(default)] - pub capabilities: Option>, - - #[serde(default)] - pub on_error: Option, -} - -/// Registry of plugin declarations, keyed by name. Built by the parser -/// from the root `plugins:` block. Type alias — no methods — so callers -/// can wrap it in `Arc<_>` or borrow it directly without ceremony. -pub type PluginRegistry = HashMap; - -/// Plugin shape after layering route-level overrides on top of the -/// global declaration. This is what invokers should consume — calling -/// `EffectivePlugin::resolve` (rather than reading the global directly) -/// ensures future override enforcement lands without re-walking the -/// dispatch sites. -#[derive(Debug, Clone)] -pub struct EffectivePlugin<'a> { - pub name: &'a str, - pub kind: &'a str, - /// NOT overridable per spec — always from the global declaration. - pub hooks: &'a [String], - /// Capabilities: route override wins if present, else global. - /// Borrowed when no override applies; owned (cloned) when override - /// present. Use [`capabilities`] to read regardless. - pub capabilities: CapsView<'a>, - /// Config: route override wins if present, else global. Borrowed - /// directly; callers that need to own it call `.cloned()`. - pub config: Option<&'a serde_yaml::Value>, - /// on_error: route override wins if present, else global. - pub on_error: Option<&'a str>, -} - -/// Internal helper that holds either a borrowed slice from the global -/// declaration or an owned override vec; callers see a slice either way. -#[derive(Debug, Clone)] -pub enum CapsView<'a> { - /// Cheap path — no override; point at the global's slice. - Global(&'a [String]), - /// Override applied — caller-owned copy from the override block. - Override(&'a [String]), -} - -impl<'a> CapsView<'a> { - pub fn as_slice(&self) -> &'a [String] { - match self { - Self::Global(s) | Self::Override(s) => s, - } - } -} - -impl<'a> EffectivePlugin<'a> { - /// Merge a global declaration with a per-route override and return - /// the effective view. Returns `None` if `name` isn't in the - /// registry — caller decides whether that's an error. - /// - /// Route-level override precedence: - /// - Override `config` replaces the global `config` entirely. - /// - Override `capabilities` replaces global capabilities. - /// - Override `on_error` replaces global on_error. - /// - Everything else inherits unchanged from the global. - pub fn resolve( - name: &str, - registry: &'a PluginRegistry, - route_overrides: &'a HashMap, - ) -> Option { - let global = registry.get(name)?; - let ovr = route_overrides.get(name); - - let capabilities = match ovr.and_then(|o| o.capabilities.as_deref()) { - Some(c) => CapsView::Override(c), - None => CapsView::Global(global.capabilities.as_slice()), - }; - let config = ovr - .and_then(|o| o.config.as_ref()) - .or(global.config.as_ref()); - let on_error = ovr - .and_then(|o| o.on_error.as_deref()) - .or(global.on_error.as_deref()); - - Some(Self { - name: global.name.as_str(), - kind: global.kind.as_str(), - hooks: global.hooks.as_slice(), - capabilities, - config, - on_error, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn yaml(s: &str) -> serde_yaml::Value { - serde_yaml::from_str(s).unwrap() - } - - fn registry_with(decl: PluginDeclaration) -> PluginRegistry { - let mut r = PluginRegistry::new(); - r.insert(decl.name.clone(), decl); - r - } - - #[test] - fn resolve_with_no_override_returns_global_values() { - let registry = registry_with(PluginDeclaration { - name: "rate_limiter".into(), - kind: "native".into(), - hooks: vec!["tool_pre_invoke".into()], - capabilities: vec!["read_subject".into()], - config: Some(yaml("max_requests: 100")), - on_error: Some("fail".into()), - extra: HashMap::new(), - }); - let overrides = HashMap::new(); - - let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap(); - assert_eq!(eff.name, "rate_limiter"); - assert_eq!(eff.kind, "native"); - assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); - assert_eq!(eff.capabilities.as_slice(), &["read_subject".to_string()]); - assert_eq!(eff.on_error, Some("fail")); - assert!(matches!(eff.capabilities, CapsView::Global(_))); - } - - #[test] - fn resolve_with_override_replaces_config_and_capabilities_and_on_error() { - let registry = registry_with(PluginDeclaration { - name: "rate_limiter".into(), - kind: "native".into(), - hooks: vec!["tool_pre_invoke".into()], - capabilities: vec!["read_subject".into()], - config: Some(yaml("max_requests: 100")), - on_error: Some("fail".into()), - extra: HashMap::new(), - }); - let mut overrides = HashMap::new(); - overrides.insert( - "rate_limiter".to_string(), - PluginOverride { - config: Some(yaml("max_requests: 10")), - capabilities: Some(vec!["read_subject".into(), "read_labels".into()]), - on_error: Some("ignore".into()), - }, - ); - - let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap(); - // Hooks NOT overridable — still the global value. - assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); - // Capabilities/config/on_error — overridden. - assert_eq!( - eff.capabilities.as_slice(), - &["read_subject".to_string(), "read_labels".to_string()] - ); - assert!(matches!(eff.capabilities, CapsView::Override(_))); - assert_eq!(eff.on_error, Some("ignore")); - let cfg = eff.config.expect("config present"); - assert_eq!(cfg["max_requests"], yaml("10")); - } - - #[test] - fn resolve_with_partial_override_only_replaces_present_keys() { - // Per spec line 399: only keys present in the override replace - // inherited values. An override with just `on_error` inherits - // config + capabilities from the global. - let registry = registry_with(PluginDeclaration { - name: "audit".into(), - kind: "native".into(), - hooks: vec!["tool_post_invoke".into()], - capabilities: vec!["read_labels".into()], - config: Some(yaml("log_level: info")), - on_error: Some("ignore".into()), - extra: HashMap::new(), - }); - let mut overrides = HashMap::new(); - overrides.insert( - "audit".to_string(), - PluginOverride { - config: None, - capabilities: None, - on_error: Some("fail".into()), - }, - ); - - let eff = EffectivePlugin::resolve("audit", ®istry, &overrides).unwrap(); - assert_eq!(eff.on_error, Some("fail")); // overridden - assert_eq!(eff.capabilities.as_slice(), &["read_labels".to_string()]); // inherited - let cfg = eff.config.expect("config inherited"); - assert_eq!(cfg["log_level"], yaml("info")); // inherited - } - - #[test] - fn resolve_returns_none_for_unknown_plugin() { - let registry = PluginRegistry::new(); - let overrides = HashMap::new(); - assert!(EffectivePlugin::resolve("missing", ®istry, &overrides).is_none()); - } -} diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs deleted file mode 100644 index 83f07364..00000000 --- a/crates/apl-core/src/route.rs +++ /dev/null @@ -1,1077 +0,0 @@ -// Location: ./crates/apl-core/src/route.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Phase orchestration: runs `args → pre_invocation → result → post_invocation` against a -// `CompiledRoute` and a mutable payload, returning a unified decision plus -// accumulated taints. -// -// This is the entry point apl-cpex calls into. Each phase has its own -// evaluator (see `evaluator.rs`); this module's job is to drive them in -// the right order with the right transitions (apply field mutations, halt -// on deny, thread taints across phases). -// -// Phase semantics: -// - args: walk field rules; Replace/Omit mutate `payload.args`; Deny halts -// - policy: walk steps; Deny halts -// - result: only runs if `payload.result.is_some()`; same as args -// - post_policy: walks steps; the spec leaves room for "observed only" -// handling, but apl-core surfaces the deny — the host (apl-cpex) chooses -// whether to enforce it -// -// Missing fields are skipped silently — a pipeline can't transform what -// isn't there. If a route needs to require presence, that's a policy-phase -// `require(exists(args.X))` rule. - -use std::sync::Arc; - -use crate::attributes::AttributeBag; -use crate::evaluator::{evaluate_effects, evaluate_pipeline, Decision, FieldOutcome}; -use crate::pipeline::TaintEvent; -use crate::rules::CompiledRoute; -use crate::step::{ - DelegationInvoker, DispatchPhase, ElicitationInvoker, PdpResolver, PluginInvoker, -}; - -/// Mutable payload for a route invocation. `args` is the request arguments -/// object; `result` is the response object (`None` on the inbound path, -/// `Some` once the tool/resource has produced a value). -#[derive(Debug, Clone)] -pub struct RoutePayload { - pub args: serde_json::Value, - pub result: Option, -} - -impl RoutePayload { - pub fn new(args: serde_json::Value) -> Self { - Self { args, result: None } - } - - pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self { - Self { - args, - result: Some(result), - } - } -} - -/// Full outcome of running all four phases for a route. -/// -/// `#[non_exhaustive]`: this outcome type keeps gaining fields as the engine -/// grows (taints, constraints, pending elicitations, …), so it is sealed -/// against external struct-literal construction — hosts read it, they don't -/// build it. New fields can be added without breaking downstream readers. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct RouteDecision { - pub decision: Decision, - /// Taints accumulated from any phase. Empty unless a pipeline emitted them. - pub taints: Vec, - /// Backend candidate constraints emitted by `restrict` effects in any - /// phase. Empty unless a `restrict` fired. The host bridge (apl-cpex) - /// folds these into a `CandidateConstraintExtension` it serializes to - /// the router. - pub constraints: Vec, - /// True if any args field was rewritten or omitted. - pub args_modified: bool, - /// True if any result field was rewritten or omitted. - pub result_modified: bool, - /// Set when a phase suspended on an unresolved elicitation. `Some` - /// means the host must emit JSON-RPC `-32120` (retry) and **not** - /// forward — `decision` is `Allow` in that case. The host forwards - /// only when `decision` is `Allow` AND `pending.is_none()`. See - /// [`crate::step::PendingElicitation`]. - pub pending: Option, -} - -/// Run the **pre-invocation** phases: `args` then `policy`. Used by -/// orchestrators bound to a `tool_pre_invoke`-style hook — by the time -/// post-invoke fires, the tool has produced a response, so result/ -/// post_policy belong to [`evaluate_post`]. -/// -/// On a phase Deny, halts and returns immediately. `args_modified` is -/// set if any args field was rewritten or omitted; `result_modified` is -/// always `false` (post hasn't run). Taints emitted during args/policy -/// land in the returned `taints` vec — survive even on a Deny so audit -/// sees what fired before the halt. -pub async fn evaluate_pre( - route: &CompiledRoute, - bag: &mut AttributeBag, - payload: &mut RoutePayload, - pdp: &Arc, - plugins: &Arc, - delegations: &Arc, - elicitations: &Arc, -) -> RouteDecision { - let mut taints: Vec = Vec::new(); - let mut args_modified = false; - - for rule in &route.args { - let Some(current) = get_dotted(&payload.args, &rule.field).cloned() else { - continue; // missing field → no pipeline to run - }; - let eval = evaluate_pipeline( - &rule.pipeline, - ¤t, - bag, - plugins, - &rule.field, - DispatchPhase::Pre, - ) - .await; - taints.extend(eval.taints); - match eval.outcome { - FieldOutcome::Pass => {}, - FieldOutcome::Replace(new_val) => { - if set_dotted(&mut payload.args, &rule.field, new_val) { - args_modified = true; - } - }, - FieldOutcome::Omit => { - if remove_dotted(&mut payload.args, &rule.field) { - args_modified = true; - } - }, - FieldOutcome::Deny { - reason, - stage_index: _, - } => { - return RouteDecision { - decision: Decision::Deny { - reason: Some(reason), - rule_source: rule.source.clone(), - }, - taints, - // `restrict` only fires in the policy phase (below); - // an args-pipeline deny short-circuits before it. - constraints: Vec::new(), - args_modified, - result_modified: false, - pending: None, - }; - }, - } - } - - let policy_eval = evaluate_effects( - &route.policy, - bag, - pdp, - plugins, - delegations, - elicitations, - DispatchPhase::Pre, - payload, - ) - .await; - // FieldOps inside `do:` may have rewritten args during policy — - // surface that to the host the same way as an `args:` pipeline. - args_modified |= policy_eval.args_modified; - taints.extend(policy_eval.taints); - RouteDecision { - decision: policy_eval.decision, - taints, - constraints: policy_eval.constraints, - args_modified, - result_modified: false, - pending: policy_eval.pending, - } -} - -/// Run the **post-invocation** phases: `result` (if a response payload -/// is present) then `post_policy`. Used by orchestrators bound to a -/// `tool_post_invoke`-style hook. -/// -/// On a phase Deny, halts. `result_modified` is set if any result field -/// was rewritten or omitted; `args_modified` is always `false` (this -/// function doesn't touch args). -pub async fn evaluate_post( - route: &CompiledRoute, - bag: &mut AttributeBag, - payload: &mut RoutePayload, - pdp: &Arc, - plugins: &Arc, - delegations: &Arc, - elicitations: &Arc, -) -> RouteDecision { - let mut taints: Vec = Vec::new(); - let mut result_modified = false; - - if let Some(result) = payload.result.as_mut() { - for rule in &route.result { - let Some(current) = get_dotted(result, &rule.field).cloned() else { - continue; - }; - let eval = evaluate_pipeline( - &rule.pipeline, - ¤t, - bag, - plugins, - &rule.field, - DispatchPhase::Post, - ) - .await; - taints.extend(eval.taints); - match eval.outcome { - FieldOutcome::Pass => {}, - FieldOutcome::Replace(new_val) => { - if set_dotted(result, &rule.field, new_val) { - result_modified = true; - } - }, - FieldOutcome::Omit => { - if remove_dotted(result, &rule.field) { - result_modified = true; - } - }, - FieldOutcome::Deny { - reason, - stage_index: _, - } => { - return RouteDecision { - decision: Decision::Deny { - reason: Some(reason), - rule_source: rule.source.clone(), - }, - taints, - // `restrict` fires in post_policy (below); a - // result-pipeline deny short-circuits before it. - constraints: Vec::new(), - args_modified: false, - result_modified, - pending: None, - }; - }, - } - } - } - - let post_eval = evaluate_effects( - &route.post_policy, - bag, - pdp, - plugins, - delegations, - elicitations, - DispatchPhase::Post, - payload, - ) - .await; - // Same reason as the policy phase: a `do:`-embedded FieldOp may - // have rewritten result fields during post_policy. - result_modified |= post_eval.result_modified; - taints.extend(post_eval.taints); - - RouteDecision { - decision: post_eval.decision, - taints, - constraints: post_eval.constraints, - args_modified: false, - result_modified, - pending: post_eval.pending, - } -} - -/// Run all four phases against `payload`, mutating it in place. -/// Convenience wrapper for callers that don't need the pre/post split -/// (tests, single-hook hosts). Calls [`evaluate_pre`] then [`evaluate_post`], -/// skipping post entirely on a pre-side Deny. Taints from both halves -/// concatenate; `args_modified` and `result_modified` carry their -/// respective flags independently. -/// -/// Orchestrators that need to fire on distinct pre/post hooks should -/// call [`evaluate_pre`] and [`evaluate_post`] separately so the post -/// half sees the payload after the tool has produced its response. -pub async fn evaluate_route( - route: &CompiledRoute, - bag: &mut AttributeBag, - payload: &mut RoutePayload, - pdp: &Arc, - plugins: &Arc, - delegations: &Arc, - elicitations: &Arc, -) -> RouteDecision { - let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations, elicitations).await; - // Halt before the tool call on a pre-side Deny OR a pending - // elicitation. Pending means the inbound phase suspended awaiting a - // human — the tool must not run, so `post` (which processes the tool's - // response) is skipped and the host emits `-32120` from `pre.pending`. - if matches!(pre.decision, Decision::Deny { .. }) || pre.pending.is_some() { - return pre; - } - let post = evaluate_post(route, bag, payload, pdp, plugins, delegations, elicitations).await; - let mut taints = pre.taints; - taints.extend(post.taints); - let mut constraints = pre.constraints; - constraints.extend(post.constraints); - RouteDecision { - decision: post.decision, - taints, - constraints, - args_modified: pre.args_modified, - result_modified: post.result_modified, - pending: post.pending, - } -} - -/// Read `root.a.b.c` from a JSON value via dot-separated path. Returns -/// `None` if any segment is missing or the path crosses a non-object. -/// -/// Public because host bridges read fields back out of their own payload -/// projections — a plugin dispatched from a pipeline stage reports a new -/// value for the field it was pointed at, and finding that field has to -/// use the same path semantics the evaluator used to write it. -pub fn get_dotted<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { - let mut cur = root; - for seg in path.split('.') { - cur = cur.get(seg)?; - } - Some(cur) -} - -/// Write to `root.a.b.c` via dot-separated path. Returns true on success; -/// false if the parent path doesn't exist or doesn't resolve to an object. -/// Does not create missing parent objects — that'd hide schema bugs. -pub(crate) fn set_dotted( - root: &mut serde_json::Value, - path: &str, - value: serde_json::Value, -) -> bool { - let parts: Vec<&str> = path.split('.').collect(); - let (leaf, parents) = match parts.split_last() { - Some(x) => x, - None => return false, - }; - let mut cur = root; - for seg in parents { - let Some(next) = cur.get_mut(*seg) else { - return false; - }; - if !next.is_object() { - return false; - } - cur = next; - } - if let serde_json::Value::Object(map) = cur { - map.insert((*leaf).to_string(), value); - true - } else { - false - } -} - -/// Remove `root.a.b.c` from a JSON value. Returns true if removal happened. -pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool { - let parts: Vec<&str> = path.split('.').collect(); - let (leaf, parents) = match parts.split_last() { - Some(x) => x, - None => return false, - }; - let mut cur = root; - for seg in parents { - let Some(next) = cur.get_mut(*seg) else { - return false; - }; - if !next.is_object() { - return false; - } - cur = next; - } - if let serde_json::Value::Object(map) = cur { - map.remove(*leaf).is_some() - } else { - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck}; - use crate::rules::{Effect, Expression, Rule}; - use crate::step::{ - NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, - PluginError, PluginInvocation, PluginOutcome, - }; - use async_trait::async_trait; - use serde_json::json; - - struct AllowPdp; - #[async_trait] - impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }) - } - } - - struct NoPlugins; - #[async_trait] - impl PluginInvoker for NoPlugins { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - Err(PluginError::NotFound(name.into())) - } - } - - /// Elicitation invoker that always reports Pending — for the - /// route-level suspend test. - struct PendingElicitor; - #[async_trait] - impl ElicitationInvoker for PendingElicitor { - async fn dispatch( - &self, - _step: &crate::step::ElicitStep, - _resolved_from: &str, - ) -> Result { - Ok(crate::step::ElicitationDispatch { - id: "elic-route-1".into(), - approver: None, - intent_id: None, - expires_at: None, - }) - } - async fn check( - &self, - _step: &crate::step::ElicitStep, - _id: &str, - ) -> Result { - Ok(crate::step::ElicitationStatus::Pending) - } - async fn validate( - &self, - _step: &crate::step::ElicitStep, - _id: &str, - ) -> Result { - unreachable!("validate must not run while pending") - } - } - - // `evaluate_route` takes `&Arc` / `&Arc` - // so the path through `dispatch_parallel` can `Arc::clone` into each - // spawned branch. These helpers wrap the no-op test stubs once per call. - fn pdp_arc() -> Arc { - Arc::new(AllowPdp) - } - fn plugins() -> Arc { - Arc::new(NoPlugins) - } - fn delegations() -> Arc { - Arc::new(NoopDelegationInvoker) - } - fn elicitations() -> Arc { - Arc::new(NoopElicitationInvoker) - } - - fn field_rule(field: &str, stages: Vec) -> FieldRule { - FieldRule { - field: field.into(), - pipeline: Pipeline { stages }, - source: format!("test.{}", field), - } - } - - fn deny_rule(source: &str, reason: &str) -> Rule { - Rule::single( - Expression::Always, - Effect::Deny { - reason: Some(reason.into()), - code: None, - }, - source, - ) - } - - #[tokio::test] - async fn empty_route_allows() { - let route = CompiledRoute::new("noop"); - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({})); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(!r.args_modified); - assert!(!r.result_modified); - assert!(r.taints.is_empty()); - } - - #[tokio::test] - async fn pending_elicitation_suspends_route_and_skips_post() { - // A pending elicitation in `policy:` must suspend the whole route: - // decision Allow + pending Some, and the `result:` phase (which - // would mutate the response) must NOT run. - let mut route = CompiledRoute::new("payroll"); - route.policy.push(Effect::Elicit(crate::step::ElicitStep { - kind: crate::step::ElicitKind::Approval, - plugin_name: "manager-approver".into(), - channel: Some("ciba".into()), - from: "user.manager".into(), - purpose: None, - scope: None, - timeout: None, - config_override: None, - on_error: None, - source: "payroll.policy[0]".into(), - })); - // A result rule that WOULD mask — proves post didn't run if untouched. - route - .result - .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }])); - - let elicitor: Arc = Arc::new(PendingElicitor); - let mut bag = AttributeBag::new(); - // The step's `from` is an attribute ref — seed it so it resolves - // (an unresolved attribute `from` now fails closed by design). - bag.set("user.manager", "manager@corp.com"); - let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789" })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitor, - ) - .await; - - assert_eq!(r.decision, Decision::Allow, "pending is not a deny"); - let bundle = r.pending.expect("route surfaced the pending bundle"); - assert_eq!(bundle.id, "elic-route-1"); - assert_eq!(bundle.plugin_name, "manager-approver"); - // post never ran → result untouched (no masking applied). - assert!(!r.result_modified); - assert_eq!( - payload.result.as_ref().unwrap()["ssn"], - json!("123-45-6789") - ); - } - - #[tokio::test] - async fn args_pipeline_mutates_payload() { - let mut route = CompiledRoute::new("ping"); - route - .args - .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }])); - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({ "ssn": "123-45-6789" })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(r.args_modified); - assert_eq!(payload.args["ssn"], json!("*******6789")); - } - - #[tokio::test] - async fn args_deny_halts_route() { - let mut route = CompiledRoute::new("ping"); - route.args.push(field_rule( - "amount", - vec![ - Stage::Type(TypeCheck::Int), - Stage::Range { - min: Some(0), - max: Some(100), - }, - ], - )); - // Also has a policy rule that would deny — should NOT be reached - // (args deny short-circuits). If reached, source would be "policy[0]" - // instead of the args rule's source. - route - .policy - .push(Effect::from(deny_rule("policy[0]", "policy denied too"))); - - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({ "amount": 200 })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("amount"), - "expected args rule source, got {}", - rule_source - ); - }, - d => panic!("expected Deny from args phase, got {:?}", d), - } - } - - #[tokio::test] - async fn args_missing_field_is_skipped() { - // Pipeline references `compensation`, payload doesn't have it → - // missing-field rule is skipped silently, route allows. - let mut route = CompiledRoute::new("ping"); - route.args.push(field_rule( - "compensation", - vec![Stage::Type(TypeCheck::Int)], - )); - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({ "other_field": 5 })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(!r.args_modified); - } - - #[tokio::test] - async fn args_omit_drops_field() { - let mut route = CompiledRoute::new("ping"); - route.args.push(field_rule("secret", vec![Stage::Omit])); - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({ "secret": "xyz", "keep": 1 })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(r.args_modified); - assert!(payload.args.get("secret").is_none()); - assert_eq!(payload.args["keep"], json!(1)); - } - - #[tokio::test] - async fn policy_deny_halts_before_result() { - let mut route = CompiledRoute::new("ping"); - route - .policy - .push(Effect::from(deny_rule("policy[0]", "blocked"))); - // Result rule should never run. - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"), - d => panic!("expected policy deny, got {:?}", d), - } - assert!(!r.result_modified); - // Result payload not mutated — redact didn't run. - assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("123")); - } - - #[tokio::test] - async fn result_phase_skipped_when_no_response() { - let mut route = CompiledRoute::new("ping"); - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({})); // no result - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(!r.result_modified); - } - - #[tokio::test] - async fn result_pipeline_redacts_field() { - let mut route = CompiledRoute::new("ping"); - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - let mut bag = AttributeBag::new(); - let mut payload = - RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789", "name": "alice" })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(r.result_modified); - let result = payload.result.as_ref().unwrap(); - assert_eq!(result["ssn"], json!("[REDACTED]")); - assert_eq!(result["name"], json!("alice")); - } - - #[tokio::test] - async fn taints_accumulate_across_phases() { - let mut route = CompiledRoute::new("ping"); - // args emits a taint - route.args.push(field_rule( - "input", - vec![Stage::Taint { - label: "args_seen".into(), - scopes: vec![TaintScope::Session], - }], - )); - // result emits a different taint - route.result.push(field_rule( - "output", - vec![Stage::Taint { - label: "result_seen".into(), - scopes: vec![TaintScope::Message], - }], - )); - let mut bag = AttributeBag::new(); - let mut payload = - RoutePayload::with_result(json!({ "input": "hello" }), json!({ "output": "world" })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - let labels: Vec<&str> = r.taints.iter().map(|t| t.label.as_str()).collect(); - assert_eq!(labels, vec!["args_seen", "result_seen"]); - } - - #[tokio::test] - async fn nested_field_path_resolves_and_writes() { - let mut route = CompiledRoute::new("ping"); - route.args.push(field_rule( - "user.profile.ssn", - vec![Stage::Mask { keep_last: 4 }], - )); - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::new(json!({ - "user": { "profile": { "ssn": "123-45-6789", "name": "alice" } } - })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(r.args_modified); - assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789")); - assert_eq!(payload.args["user"]["profile"]["name"], json!("alice")); - } - - #[tokio::test] - async fn nested_field_missing_intermediate_is_skipped() { - let mut route = CompiledRoute::new("ping"); - route.args.push(field_rule( - "user.profile.ssn", - vec![Stage::Mask { keep_last: 4 }], - )); - let mut bag = AttributeBag::new(); - // `profile` segment is missing → get_dotted returns None → skip. - let mut payload = RoutePayload::new(json!({ "user": { "name": "alice" } })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(!r.args_modified); - } - - #[tokio::test] - async fn post_policy_runs_after_result() { - let mut route = CompiledRoute::new("ping"); - // Result mutates a field, then post_policy denies. - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - route - .post_policy - .push(Effect::from(deny_rule("post_policy[0]", "after-the-fact"))); - - let mut bag = AttributeBag::new(); - let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" })); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"), - d => panic!("expected post_policy deny, got {:?}", d), - } - // Result was still mutated before the post_policy deny fired. - assert!(r.result_modified); - assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]")); - } - - #[test] - fn dotted_get_simple_and_nested() { - let v = json!({ "a": { "b": { "c": 7 } } }); - assert_eq!(get_dotted(&v, "a.b.c"), Some(&json!(7))); - assert_eq!(get_dotted(&v, "a.b"), Some(&json!({ "c": 7 }))); - assert!(get_dotted(&v, "a.b.x").is_none()); - assert!(get_dotted(&v, "missing").is_none()); - } - - #[test] - fn dotted_set_overwrites_leaf() { - let mut v = json!({ "a": { "b": 1 } }); - assert!(set_dotted(&mut v, "a.b", json!(99))); - assert_eq!(v["a"]["b"], json!(99)); - } - - #[test] - fn dotted_set_does_not_create_missing_parents() { - // Strict: if `a.b` parent doesn't exist, set fails (no auto-vivify). - let mut v = json!({}); - assert!(!set_dotted(&mut v, "a.b", json!(1))); - assert_eq!(v, json!({})); - } - - #[test] - fn dotted_remove_leaf() { - let mut v = json!({ "a": { "b": 1, "c": 2 } }); - assert!(remove_dotted(&mut v, "a.b")); - assert_eq!(v, json!({ "a": { "c": 2 } })); - assert!(!remove_dotted(&mut v, "a.b")); - } - - #[tokio::test] - async fn evaluate_pre_runs_args_and_policy_only() { - // Route with both args validators + result transforms. evaluate_pre - // should run args (mutating payload.args), policy (allow here), - // but NOT result — payload.result stays exactly as given. - let mut route = CompiledRoute::new("test"); - route - .args - .push(field_rule("id", vec![Stage::Mask { keep_last: 2 }])); - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - - let mut payload = - RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" })); - let mut bag = AttributeBag::new(); - let r = evaluate_pre( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!( - r.args_modified, - "args mask stage should have rewritten the field" - ); - assert!(!r.result_modified, "evaluate_pre must not touch result"); - // Args was rewritten by mask(2). - assert_eq!(payload.args["id"], json!("******GH")); - // Result is untouched — post hasn't run. - assert_eq!( - payload.result.as_ref().unwrap()["ssn"], - json!("555-12-3456") - ); - } - - #[tokio::test] - async fn evaluate_post_runs_result_and_post_policy_only() { - // Route with args + result. evaluate_post skips args entirely - // (no mutation), runs result + post_policy. - let mut route = CompiledRoute::new("test"); - route - .args - .push(field_rule("id", vec![Stage::Mask { keep_last: 2 }])); - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - - let mut payload = - RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" })); - let mut bag = AttributeBag::new(); - let r = evaluate_post( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(!r.args_modified, "evaluate_post must not touch args"); - assert!(r.result_modified, "result redact should have fired"); - // Args is untouched by evaluate_post. - assert_eq!(payload.args["id"], json!("ABCDEFGH")); - // Result was redacted. - assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]")); - } - - #[tokio::test] - async fn evaluate_pre_deny_halts_before_policy() { - // Args has a type validator that fails → pre denies before policy runs. - let mut route = CompiledRoute::new("test"); - route - .args - .push(field_rule("id", vec![Stage::Type(TypeCheck::Uuid)])); - // Policy that would always deny if it ran — assert it doesn't. - route.policy.push(Effect::from(Rule::single( - Expression::Always, - Effect::Deny { - reason: Some("policy_should_not_run".into()), - code: None, - }, - "test.policy[0]", - ))); - - let mut payload = RoutePayload::new(json!({ "id": "not-a-uuid" })); - let mut bag = AttributeBag::new(); - let r = evaluate_pre( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("test.id"), - "args denial got source {}", - rule_source - ); - }, - d => panic!("expected args-side Deny, got {:?}", d), - } - } - - #[tokio::test] - async fn evaluate_route_skips_post_on_pre_deny() { - // Wrapper preserves "deny halts before post" — proves the - // refactor didn't regress evaluate_route's semantics. - let mut route = CompiledRoute::new("test"); - route.policy.push(Effect::from(Rule::single( - Expression::Always, - Effect::Deny { - reason: Some("policy_deny".into()), - code: None, - }, - "test.policy[0]", - ))); - route - .result - .push(field_rule("ssn", vec![Stage::Redact { condition: None }])); - route.post_policy.push(Effect::Taint { - label: "should_not_emit".into(), - scopes: vec![TaintScope::Session], - }); - - let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "555-12-3456" })); - let mut bag = AttributeBag::new(); - let r = evaluate_route( - &route, - &mut bag, - &mut payload, - &pdp_arc(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert!(matches!(r.decision, Decision::Deny { .. })); - assert!(!r.result_modified, "post must be skipped on pre-side Deny"); - // post_policy never ran, so its taint never landed. - assert!(r.taints.is_empty()); - // Result untouched. - assert_eq!( - payload.result.as_ref().unwrap()["ssn"], - json!("555-12-3456") - ); - } -} diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs deleted file mode 100644 index 95c50c8b..00000000 --- a/crates/apl-core/src/rules.rs +++ /dev/null @@ -1,1023 +0,0 @@ -// Location: ./crates/apl-core/src/rules.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// APL intermediate representation. -// -// The compiler (later) produces a `CompiledRoute` per route_key from -// YAML / database / any other ConfigSource. The evaluator (later) -// consumes the IR plus an AttributeBag and returns a decision. -// -// IR types are kept small and pure-data — no dependencies on cpex-core -// extensions, no evaluation logic. - -use serde::{Deserialize, Serialize}; - -/// Comparison operators in DSL predicates. -/// -/// `In` / `NotIn` are intentionally absent: the DSL spec has them as -/// `value_key in set_key` — both sides are attribute references, not a -/// key-vs-literal shape. They'll land as a dedicated `Condition` variant -/// when the parser arrives. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CompareOp { - Eq, - NotEq, - Gt, - GtEq, - Lt, - LtEq, - /// ` contains ` — left is a StringSet attribute, - /// right is a string literal. - Contains, -} - -/// Right-hand side of a comparison. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum Literal { - Bool(bool), - Int(i64), - Float(f64), - String(String), -} - -impl From for Literal { - fn from(v: bool) -> Self { - Literal::Bool(v) - } -} -impl From for Literal { - fn from(v: i64) -> Self { - Literal::Int(v) - } -} -impl From for Literal { - fn from(v: f64) -> Self { - Literal::Float(v) - } -} -impl From<&str> for Literal { - fn from(v: &str) -> Self { - Literal::String(v.to_string()) - } -} -impl From for Literal { - fn from(v: String) -> Self { - Literal::String(v) - } -} - -/// Leaf predicate. -/// -/// `Comparison` covers `key op value`. The truthiness checks are split out -/// (`IsTrue` / `IsFalse`) because they're the most common form — `authenticated`, -/// `role.hr`, `delegated`. -/// -/// The DSL's `require(...)` keyword is **not** represented here — it's a -/// rule-level shorthand for "deny when the condition fails," and the parser -/// desugars it into `Not` / `And` / `Or` over `IsFalse` expressions plus -/// an `Action::Deny`. See the DSL spec desugarings. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Condition { - Comparison { - key: String, - op: CompareOp, - value: Literal, - }, - IsTrue { - key: String, - }, - IsFalse { - key: String, - }, - /// DSL `exists(key)` — true iff the key is present in the - /// AttributeBag, regardless of its value. Distinct from `IsTrue` - /// (which only succeeds for truthy values). - Exists { - key: String, - }, - /// DSL `value_key in set_key` (negate=false) / `value_key not in set_key` - /// (negate=true). Both operands are attribute keys, not literals — the - /// scalar at `value_key` is checked for membership in the StringSet at - /// `set_key`. Returns `false` if either key is missing or - /// the types don't match (scalar must resolve to a string). - InSet { - value_key: String, - set_key: String, - negate: bool, - }, -} - -/// Compound predicate. -/// -/// `Always` is the implicit-true predicate for bare-effect rules: -/// `- plugin(rate_limiter)` / `- taint(audit)` / unconditional -/// `- deny` / `- allow`. It's never produced by predicate-string parsing -/// — only by rule-level forms where no `when:` is supplied. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Expression { - Condition(Condition), - And(Vec), - Or(Vec), - Not(Box), - Always, -} - -/// One thing a matching rule does. Mirrors the DSL effect classes: -/// -/// * Control — `Allow`, `Deny` -/// * Label — `Taint` -/// * Host — `Plugin`, `Delegate` -/// -/// Content effects (`redact`, `mask`, `omit`, `hash`) and orchestration -/// (`Sequential`, `Parallel`) land in later work. -/// -/// PDP calls (`cedar:(…)`, `opa(…)`, …) remain top-level [`Step`] -/// variants for now; folding them into `Effect` is a cleanup. -/// -/// # Inside a `Vec` (a rule's `effects` body) -/// -/// * `Allow` is a no-op — lets evaluation continue to the next effect -/// in the list, then to the next step in `policy:`. -/// * `Deny` short-circuits the rest of the list, the rest of the -/// `policy:` block, and the route. The `reason` propagates into -/// the violation message. -/// * `Plugin` / `Delegate` dispatch identically to their top-level -/// `Step` counterparts (same invoker traits). -/// * `Taint` accumulates into the phase's taint events. -/// -/// [`Step`]: crate::step::Step -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Effect { - Allow, - Deny { - reason: Option, - /// Author-supplied stable violation code. When `Some`, it - /// overrides the rule's auto-generated source-position code - /// (`routes.tool:X.apl.policy[N]`) downstream. Useful when - /// MCP clients want to dispatch on category (`quota.exceeded`, - /// `delegation.depth_exceeded`) rather than position, or when - /// multiple routes share a deny category that should - /// aggregate consistently in audit dashboards. When `None`, - /// the evaluator falls back to `rule.source` as the code — - /// matches the historical behavior. - /// - /// Parser shape: `deny('reason', 'code')` (two positional - /// arguments) or the structured `deny: { reason: ..., code: ... }` - /// map form. - code: Option, - }, - Plugin { - name: String, - }, - Delegate(crate::step::DelegateStep), - /// Elicitation effect — dispatch a question to a human (approval, - /// confirmation, step-up, …) through a channel plugin, hold pending - /// state across the agent's retries, validate the response, resume. - /// The elicitation analogue of `Delegate`. See - /// [`crate::step::ElicitStep`]. - Elicit(crate::step::ElicitStep), - Taint { - label: String, - scopes: Vec, - }, - /// Backend candidate constraint (DSL — the `restrict` effect). Narrows - /// the set of backends the host's router/LB may select from; never - /// picks a backend and never allows/denies. Accumulating, in the same - /// family as `Taint`: the evaluator collects the constraint, a higher - /// layer folds it into a `CandidateConstraintExtension` the host - /// serializes to its router. - Restrict { - spec: crate::constraint::RestrictSpec, - }, - /// Content effect — apply a pipe chain (`redact`, `mask`, - /// `omit`, `hash`, validators, transforms) to a field in the - /// route's args or result. The author writes - /// `result.salary | redact` inside a `do:` body; the parser - /// splits the dotted path from the pipeline. - /// - /// `path` must start with `args.` or `result.` — the evaluator - /// dispatches the lookup against `RoutePayload.args` or - /// `RoutePayload.result`. A FieldOp inside a Pre-phase route's - /// `do:` that targets `result.X` is a no-op (the result hasn't - /// been produced yet); same goes for a Post-phase rule that - /// targets `args.X` (the args are already on the wire). The - /// evaluator silently skips out-of-phase ops so the same - /// `when:`/`do:` shape can describe both phases without - /// branching. - FieldOp { - path: String, - stages: Vec, - }, - /// Run a list of effects in declaration order, stopping on the - /// first Deny. Semantically equivalent to inlining the list into - /// the enclosing scope; the variant exists to make grouping - /// explicit and to pair with `Parallel`. - Sequential(Vec), - /// Run a list of effects concurrently. Any Deny → overall Deny. - /// Taints from all branches accumulate. Bag and payload mutations - /// inside parallel branches are **discarded** when the branch - /// completes — each branch gets a clone of the state, never the - /// shared mutable original. Plugins inside `Parallel` can still - /// emit taints (those merge); any other mutation they try to make - /// (bag writes, args/result rewrites) vanishes. - /// - /// Config-load rejects `FieldOp` and `Delegate` directly inside - /// `Parallel` (recursively), since both would silently drop their - /// effect. The escape valve is `Sequential`. - Parallel(Vec), - /// Predicate-gated body. `body` runs in order when `condition` - /// evaluates to true; any Deny in the body halts the surrounding - /// phase. Replaces the historical `Step::Rule(Rule)` shape — - /// `when:` / `do:` directly desugars to this. A bare `require(X)` - /// or `deny(X)` shorthand compiles to `When { condition: X, - /// body: vec![Effect::Allow / Deny] }`. - /// - /// `source` is the human-readable origin (e.g. `"routes.X.policy[2]"`) - /// surfaced in `Decision::Deny.rule_source` when the body denies - /// without supplying its own code. - When { - condition: Expression, - body: Vec, - source: String, - }, - /// External PDP call. `on_allow` / `on_deny` are reaction effect - /// lists fired against the PDP's decision. Replaces - /// `Step::Pdp { ... }` — `args`-shape stays identical. - Pdp { - call: crate::step::PdpCall, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - on_allow: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - on_deny: Vec, - }, -} - -impl Effect { - /// Walk this effect (and any nested effects) checking whether any - /// node would mutate route state. Used by the config-load - /// validator to reject `FieldOp` / `Delegate` inside `Parallel` - /// since both would silently drop their effect in a discarded - /// branch. - pub fn contains_mutation(&self) -> bool { - match self { - // `Elicit` has an external side effect (posts to a channel, - // registers an intent) — like `Delegate` it must not sit in - // a `Parallel` branch that could be silently discarded. - Effect::FieldOp { .. } | Effect::Delegate(_) | Effect::Elicit(_) => true, - Effect::Sequential(effects) | Effect::Parallel(effects) => { - effects.iter().any(Effect::contains_mutation) - }, - Effect::When { body, .. } => body.iter().any(Effect::contains_mutation), - Effect::Pdp { - on_allow, on_deny, .. - } => { - on_allow.iter().any(Effect::contains_mutation) - || on_deny.iter().any(Effect::contains_mutation) - }, - Effect::Allow - | Effect::Deny { .. } - | Effect::Plugin { .. } - | Effect::Taint { .. } - | Effect::Restrict { .. } => { - // `Restrict` emits a candidate constraint, never touches the - // route payload — non-mutating, same as `Taint`. Safe inside - // `parallel:` (its constraint accumulates, like a taint label). - false - }, - } - } - - /// Walk the effect tree rejecting any `FieldOp` / `Delegate` that - /// lives directly or transitively under a `Parallel` node. Returns - /// the path string of the first violation found (or `Ok(())` if - /// the tree is clean). Run at config-load. - pub fn validate_parallel_purity(&self) -> Result<(), String> { - match self { - Effect::Parallel(effects) => { - for e in effects { - if e.contains_mutation() { - return Err(format!( - "`parallel:` contains a mutation effect ({:?}); \ - use `sequential:` for ordered mutations", - e - )); - } - // Still validate nested parallels even if this one - // is "clean at the top" — e.g. parallel → sequential - // → parallel(field_op) is still illegal. - e.validate_parallel_purity()?; - } - Ok(()) - }, - Effect::Sequential(effects) => { - for e in effects { - e.validate_parallel_purity()?; - } - Ok(()) - }, - Effect::When { body, .. } => { - for e in body { - e.validate_parallel_purity()?; - } - Ok(()) - }, - Effect::Pdp { - on_allow, on_deny, .. - } => { - for e in on_allow.iter().chain(on_deny.iter()) { - e.validate_parallel_purity()?; - } - Ok(()) - }, - _ => Ok(()), - } - } -} - -/// One compiled rule: a predicate plus the effects to fire when it -/// matches. -/// -/// `effects` is always non-empty for parser-produced rules. The -/// historical "single Allow/Deny" cases are represented by a one-element -/// `Vec` — slightly more allocation than a flat enum, but keeps one -/// dispatch path instead of two and eliminates the ambiguity of having -/// both `Action::Allow` and `Effect::Allow` in the IR. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Rule { - pub condition: Expression, - pub effects: Vec, - /// Human-readable source (original YAML line, file path, etc.). - /// Surfaces in audit logs and policy violation diagnostics. - pub source: String, -} - -impl Rule { - /// Construct a single-effect rule. Convenience for the common - /// `Allow` / `Deny` shapes that don't need a `vec![]` at the - /// call site. - pub fn single(condition: Expression, effect: Effect, source: impl Into) -> Self { - Self { - condition, - effects: vec![effect], - source: source.into(), - } - } -} - -/// `Rule` is structurally identical to `Effect::When`. The From impl lets -/// callers that already hold a `Rule` (notably the parser's inner helpers -/// and the test fixtures) drop a `.into()` instead of re-spelling all -/// three fields. Bridges the few remaining producers while the migration -/// completes; will probably stay long-term because the parser still -/// builds Rule incrementally before deciding it's an Effect::When. -impl From for Effect { - fn from(r: Rule) -> Effect { - Effect::When { - condition: r.condition, - body: r.effects, - source: r.source, - } - } -} - -/// One of the four lifecycle phases the evaluator runs per route. -/// -/// The `PolicyEvaluator` trait has one -/// async method per phase. `declared_phases()` lets the host skip phases -/// the route doesn't use. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Phase { - Args, - Policy, - Result, - PostPolicy, -} - -/// Bit-packed set of phases a route declared. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct PhaseSet(u8); - -impl PhaseSet { - pub fn new() -> Self { - Self(0) - } - - pub fn insert(&mut self, p: Phase) { - self.0 |= Self::bit(p); - } - - pub fn contains(&self, p: Phase) -> bool { - self.0 & Self::bit(p) != 0 - } - - pub fn is_empty(&self) -> bool { - self.0 == 0 - } - - fn bit(p: Phase) -> u8 { - match p { - Phase::Args => 0b0001, - Phase::Policy => 0b0010, - Phase::Result => 0b0100, - Phase::PostPolicy => 0b1000, - } - } -} - -/// Custom response to attach when a route's policy denies (e.g., equivalent -/// to a Kuadrant `AuthPolicy` `response.unauthorized` `denyWith`). -/// Carried on the route and surfaced on the deny outcome's -/// `details` map by the host (apl-cpex), so a host can render a custom -/// HTTP response. All fields optional; an absent block leaves the host's -/// default denial response unchanged. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct DenyResponse { - /// HTTP status to use for the denial (e.g. 403, 302). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, - /// Response body. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub body: Option, - /// Response headers (e.g. `Location` for a redirect, `WWW-Authenticate`). - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub headers: std::collections::BTreeMap, -} - -/// Compiler output for a single route. -/// -/// One `CompiledRoute` per route_key. The compiler merges global / default / -/// tag / route-specific rules from the config hierarchy down into these four -/// phase lists before the evaluator sees them — the IR has no notion of -/// "tag rules" or "route overrides," only "steps that fire in phase P." -/// -/// `args` and `result` are per-field pipelines (validators + transforms). -/// `policy` and `post_policy` are step lists — predicate-and-action rules -/// plus PDP calls, plugin invocations, and taint effects. See -/// apl-dsl-spec. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CompiledRoute { - pub route_key: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub args: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub policy: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub result: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub post_policy: Vec, - /// Per-plugin overrides declared on this route's `plugins:` block. - /// Keyed by plugin name; merged at dispatch time via - /// `EffectivePlugin::resolve(name, registry, &this.plugin_overrides)`. - /// Per spec only `config`, `capabilities`, `on_error` are overridable; - /// hooks/kind/source always come from the global declaration. - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - pub plugin_overrides: std::collections::HashMap, - - /// Custom denial response (transpiled `denyWith`). Most-specific layer - /// wins on collision. `None` leaves the host's default denial behavior. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub response: Option, -} - -impl CompiledRoute { - pub fn new(route_key: impl Into) -> Self { - Self { - route_key: route_key.into(), - ..Default::default() - } - } - - /// Which phases this route uses. Empty phases are not declared. - pub fn declared_phases(&self) -> PhaseSet { - let mut set = PhaseSet::new(); - if !self.args.is_empty() { - set.insert(Phase::Args); - } - if !self.policy.is_empty() { - set.insert(Phase::Policy); - } - if !self.result.is_empty() { - set.insert(Phase::Result); - } - if !self.post_policy.is_empty() { - set.insert(Phase::PostPolicy); - } - set - } - - /// Apply a more-specific policy layer on top of this one. Used by - /// orchestrators (apl-cpex's visitor) to stack the unified-config - /// hierarchy least-to-most-specific: - /// - /// ```text - /// effective = CompiledRoute::default() - /// effective.apply_layer(global_block) - /// effective.apply_layer(default_block) - /// effective.apply_layer(tag_block) - /// effective.apply_layer(route_block) - /// ``` - /// - /// Each call adds the parameter on top of what's already there; - /// `more_specific` wins on collisions because it represents a - /// later/narrower layer in the inheritance chain. - /// - /// Merge semantics: - /// - **`policy` / `post_policy`**: `more_specific`'s steps append - /// *after* self's. Earlier layers run first — globals deny before - /// route-specific rules get a chance. - /// - **`args` / `result`**: per-field; if both layers declare the - /// same field, `more_specific`'s rule replaces self's. Fields - /// only in self stay; fields only in `more_specific` are added. - /// - **`plugin_overrides`**: HashMap merge; `more_specific` wins - /// on key collisions, otherwise prefix's entries fill gaps. - /// - /// `self.route_key` is preserved — apply_layer doesn't overwrite - /// identity, just policy content. - pub fn apply_layer(&mut self, more_specific: CompiledRoute) { - // policy / post_policy: more_specific's steps append AFTER self. - // Order of accumulated calls = order of evaluation. - self.policy.extend(more_specific.policy); - self.post_policy.extend(more_specific.post_policy); - - // args: more_specific wins on field collision — drop any self.args - // entries the new layer redefines, then push the new layer's. - let ms_fields: std::collections::HashSet = - more_specific.args.iter().map(|f| f.field.clone()).collect(); - self.args.retain(|f| !ms_fields.contains(&f.field)); - self.args.extend(more_specific.args); - - // result: same shape as args. - let ms_result_fields: std::collections::HashSet = more_specific - .result - .iter() - .map(|f| f.field.clone()) - .collect(); - self.result.retain(|f| !ms_result_fields.contains(&f.field)); - self.result.extend(more_specific.result); - - // plugin_overrides: HashMap::extend overwrites on key collision, - // which is exactly the more_specific-wins semantic. - self.plugin_overrides.extend(more_specific.plugin_overrides); - - // response: deliberately NOT layered. A custom denial response is - // scope-local — the entity-less HTTP handler carries the `global` - // block directly, and an entity route carries only its own - // `response:`. Propagating it here let a `global` catch-all - // `denyWith` leak onto every inherited entity (MCP tool / llm / - // prompt / resource) denial with no way to opt back out. Callers - // set `response` explicitly at the scope that owns it. - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn apply_layer_does_not_propagate_response() { - // `response` is scope-local and must never cross a layer boundary — - // a `global` catch-all denyWith must not leak onto entity routes. - let mut base = CompiledRoute::new("tool:x"); - base.response = Some(DenyResponse { - status: Some(401), - ..Default::default() - }); - - let mut layer = CompiledRoute::new("tool:x"); - layer.response = Some(DenyResponse { - status: Some(403), - body: Some("forbidden".to_string()), - ..Default::default() - }); - base.apply_layer(layer); - // base keeps its own response; the layer's is dropped. - assert_eq!(base.response.as_ref().unwrap().status, Some(401)); - - // A layer's response never populates an empty base either. - let mut empty = CompiledRoute::new("tool:x"); - let mut with_resp = CompiledRoute::new("tool:x"); - with_resp.response = Some(DenyResponse { - status: Some(418), - ..Default::default() - }); - empty.apply_layer(with_resp); - assert!(empty.response.is_none()); - } - - #[test] - fn phase_set_basic() { - let mut set = PhaseSet::new(); - assert!(set.is_empty()); - set.insert(Phase::Policy); - set.insert(Phase::Result); - assert!(set.contains(Phase::Policy)); - assert!(set.contains(Phase::Result)); - assert!(!set.contains(Phase::Args)); - assert!(!set.contains(Phase::PostPolicy)); - assert!(!set.is_empty()); - } - - #[test] - fn compiled_route_declared_phases() { - let mut route = CompiledRoute::new("get_compensation"); - assert!(route.declared_phases().is_empty()); - - route.policy.push(Effect::When { - condition: Expression::Condition(Condition::IsTrue { - key: "authenticated".into(), - }), - body: vec![Effect::Allow], - source: "policy[0]".into(), - }); - let phases = route.declared_phases(); - assert!(phases.contains(Phase::Policy)); - assert!(!phases.contains(Phase::Args)); - } - - #[test] - fn literal_from_impls() { - // From impls keep test/builder code readable. - let r = Rule { - condition: Expression::Condition(Condition::Comparison { - key: "delegation.depth".into(), - op: CompareOp::Gt, - value: 2_i64.into(), - }), - effects: vec![Effect::Deny { - reason: Some("too deep".into()), - code: None, - }], - source: "policy[0]".into(), - }; - if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { - assert_eq!(value, Literal::Int(2)); - } else { - panic!("expected Comparison"); - } - } - - #[test] - fn rule_serde_roundtrip() { - let r = Rule { - condition: Expression::And(vec![ - Expression::Condition(Condition::IsTrue { - key: "authenticated".into(), - }), - Expression::Condition(Condition::Comparison { - key: "delegation.depth".into(), - op: CompareOp::LtEq, - value: 3_i64.into(), - }), - ]), - effects: vec![Effect::Allow], - source: "policy[1]".into(), - }; - let json = serde_json::to_string(&r).unwrap(); - let back: Rule = serde_json::from_str(&json).unwrap(); - // No PartialEq on Rule (would force PartialEq on Action's variants - // with floats etc.); spot-check the discriminator path instead. - assert!(matches!(back.effects.as_slice(), [Effect::Allow])); - assert_eq!(back.source, "policy[1]"); - } - - #[test] - fn compiled_route_serde_skips_empty_phases() { - let route = CompiledRoute::new("ping"); - let json = serde_json::to_string(&route).unwrap(); - // Empty phase vecs should not serialize — keeps audit logs clean. - assert_eq!(json, r#"{"route_key":"ping"}"#); - } - - #[test] - fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() { - // Start with global (least specific), then layer route on top. - // After: global.policy[0] runs first, route.policy[0] runs second. - let mut effective = CompiledRoute::new("route.get_compensation"); - // Seed effective with global content (simulating having already - // applied the global layer once). - effective.policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "global.policy[0]".into(), - }); - effective.post_policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "global.post_policy[0]".into(), - }); - - // Now apply the route-specific layer on top. - let mut route_layer = CompiledRoute::new("ignored"); - route_layer.policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "route.policy[0]".into(), - }); - route_layer.post_policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "route.post_policy[0]".into(), - }); - - effective.apply_layer(route_layer); - - // global ran first, route ran second — first-deny-wins respects - // the hierarchy. - assert_eq!(effective.policy.len(), 2); - match &effective.policy[0] { - Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"), - _ => panic!(), - } - match &effective.policy[1] { - Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"), - _ => panic!(), - } - assert_eq!(effective.post_policy.len(), 2); - - // route_key preserved (apply_layer doesn't touch identity). - assert_eq!(effective.route_key, "route.get_compensation"); - } - - #[test] - fn apply_layer_args_more_specific_wins_on_field_collision() { - use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck}; - - // Start with the default (less specific) layer. - let mut effective = CompiledRoute::new("route.X"); - effective.args.push(FieldRule { - field: "id".into(), - pipeline: Pipeline { - stages: vec![Stage::Type(TypeCheck::Str)], - }, - source: "default.args.id".into(), - }); - effective.args.push(FieldRule { - field: "trace_id".into(), - pipeline: Pipeline { - stages: vec![Stage::Type(TypeCheck::Str)], - }, - source: "default.args.trace_id".into(), - }); - - // Layer route (more specific) on top — it redefines `id`. - let mut route_layer = CompiledRoute::new("ignored"); - route_layer.args.push(FieldRule { - field: "id".into(), - pipeline: Pipeline { - stages: vec![Stage::Type(TypeCheck::Uuid)], - }, - source: "route.args.id".into(), - }); - - effective.apply_layer(route_layer); - - assert_eq!(effective.args.len(), 2); - // `id` is now the route's (Uuid), not the default's (Str). - let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap(); - assert!(matches!( - id_rule.pipeline.stages[0], - Stage::Type(TypeCheck::Uuid) - )); - assert_eq!(id_rule.source, "route.args.id"); - // `trace_id` survives from the default — route didn't touch it. - let trace = effective - .args - .iter() - .find(|f| f.field == "trace_id") - .unwrap(); - assert_eq!(trace.source, "default.args.trace_id"); - } - - #[test] - fn apply_layer_plugin_overrides_more_specific_wins() { - use crate::plugin_decl::PluginOverride; - - // Default (less specific) layer. - let mut effective = CompiledRoute::new("route.X"); - effective.plugin_overrides.insert( - "rate_limiter".into(), - PluginOverride { - on_error: Some("ignore".into()), - ..Default::default() - }, - ); - effective.plugin_overrides.insert( - "audit_logger".into(), - PluginOverride { - on_error: Some("ignore".into()), - ..Default::default() - }, - ); - - // Route (more specific) layer overrides rate_limiter. - let mut route_layer = CompiledRoute::new("ignored"); - route_layer.plugin_overrides.insert( - "rate_limiter".into(), - PluginOverride { - on_error: Some("fail".into()), - ..Default::default() - }, - ); - - effective.apply_layer(route_layer); - - assert_eq!(effective.plugin_overrides.len(), 2); - assert_eq!( - effective.plugin_overrides["rate_limiter"] - .on_error - .as_deref(), - Some("fail"), - "route's override wins on collision", - ); - // audit_logger untouched — route didn't redefine it. - assert_eq!( - effective.plugin_overrides["audit_logger"] - .on_error - .as_deref(), - Some("ignore"), - ); - } - - #[test] - fn apply_layer_chained_walks_hierarchy_in_specificity_order() { - // Build effective policy by applying layers least-to-most-specific. - // Mirrors how AplConfigVisitor will compose global/default/tag/route. - let mut effective = CompiledRoute::new("route.get_compensation"); - - let mut global = CompiledRoute::default(); - global.policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "global.policy[0]".into(), - }); - - let mut default = CompiledRoute::default(); - default.policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "default.policy[0]".into(), - }); - - let mut tag = CompiledRoute::default(); - tag.policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "tag.hr.policy[0]".into(), - }); - - let mut route = CompiledRoute::default(); - route.policy.push(Effect::When { - condition: Expression::Always, - body: vec![Effect::Allow], - source: "route.policy[0]".into(), - }); - - effective.apply_layer(global); - effective.apply_layer(default); - effective.apply_layer(tag); - effective.apply_layer(route); - - // Order of calls = order of evaluation. global runs first, - // route runs last (first-deny-wins lets globals deny early). - let sources: Vec<&str> = effective - .policy - .iter() - .map(|s| match s { - Effect::When { source, .. } => source.as_str(), - _ => "", - }) - .collect(); - assert_eq!( - sources, - vec![ - "global.policy[0]", - "default.policy[0]", - "tag.hr.policy[0]", - "route.policy[0]", - ] - ); - } - - #[test] - fn validate_parallel_pure_block_passes() { - // A parallel block of read-only effects validates clean. - let effect = Effect::Parallel(vec![ - Effect::Plugin { - name: "rate_limiter".into(), - }, - Effect::Plugin { - name: "audit".into(), - }, - Effect::Allow, - ]); - assert!(effect.validate_parallel_purity().is_ok()); - } - - #[test] - fn validate_parallel_rejects_field_op() { - // FieldOp would silently lose its mutation in a discarded - // branch — config-load surfaces this loudly. - let effect = Effect::Parallel(vec![ - Effect::Plugin { - name: "audit".into(), - }, - Effect::FieldOp { - path: "args.ssn".into(), - stages: vec![], - }, - ]); - let err = effect.validate_parallel_purity().unwrap_err(); - assert!(err.contains("mutation"), "got: {}", err); - assert!(err.contains("FieldOp"), "should name the offender: {}", err); - } - - #[test] - fn validate_parallel_rejects_delegate() { - // Same reason as FieldOp — the minted token would land in a - // bag that gets discarded. - let delegate = Effect::Delegate(crate::step::DelegateStep { - plugin_name: "workday".into(), - config_override: None, - on_error: None, - source: "test".into(), - }); - let effect = Effect::Parallel(vec![Effect::Allow, delegate]); - let err = effect.validate_parallel_purity().unwrap_err(); - assert!(err.contains("mutation")); - } - - #[test] - fn validate_parallel_recurses_into_nested_parallel() { - // `parallel → sequential → parallel(field_op)` — the inner - // parallel still illegal. Recursion must catch it. - let inner_parallel = Effect::Parallel(vec![Effect::FieldOp { - path: "args.x".into(), - stages: vec![], - }]); - let outer = Effect::Parallel(vec![Effect::Sequential(vec![ - Effect::Allow, - inner_parallel, - ])]); - assert!(outer.validate_parallel_purity().is_err()); - } - - #[test] - fn validate_top_level_sequential_allows_mutations() { - // FieldOp / Delegate are allowed under Sequential (or at top - // level) — only Parallel rejects them. - let effect = Effect::Sequential(vec![ - Effect::FieldOp { - path: "args.ssn".into(), - stages: vec![], - }, - Effect::Allow, - ]); - assert!(effect.validate_parallel_purity().is_ok()); - } - - #[test] - fn validate_contains_mutation_classifies_each_variant() { - // White-box check on the helper so future Effect additions - // get flagged here when they should be classified. - assert!(!Effect::Allow.contains_mutation()); - assert!(!Effect::Deny { - reason: None, - code: None - } - .contains_mutation()); - assert!(!Effect::Plugin { name: "x".into() }.contains_mutation()); - assert!(!Effect::Taint { - label: "x".into(), - scopes: vec![], - } - .contains_mutation()); - - assert!(Effect::FieldOp { - path: "args.x".into(), - stages: vec![], - } - .contains_mutation()); - assert!(Effect::Delegate(crate::step::DelegateStep { - plugin_name: "x".into(), - config_override: None, - on_error: None, - source: "x".into(), - }) - .contains_mutation()); - - // Composite — mutates iff any child mutates. - let pure_seq = Effect::Sequential(vec![Effect::Allow]); - assert!(!pure_seq.contains_mutation()); - let dirty_seq = Effect::Sequential(vec![Effect::FieldOp { - path: "args.x".into(), - stages: vec![], - }]); - assert!(dirty_seq.contains_mutation()); - } -} diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs deleted file mode 100644 index c4351b8a..00000000 --- a/crates/apl-core/src/step.rs +++ /dev/null @@ -1,1042 +0,0 @@ -// Location: ./crates/apl-core/src/step.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Policy-phase Step IR and async dispatch traits. -// -// The DSL allows policy:/post_policy: lists to contain three kinds of -// entries beyond predicate-and-action rules: -// -// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)`, -// `cel:(...)` with optional `on_deny:` / `on_allow:` reaction blocks -// - Plugin invocations: `plugin(name)` -// - Taint effects: `taint(label[, scope])` -// -// `Step` is the union over these forms plus the existing `Rule`. The async -// `evaluate_steps` function walks a Step list, dispatching PDP calls via -// `PdpResolver` and plugin calls via `PluginInvoker`. Taint dispatch is -// recognized but no-op in apl-core — actual SessionStore writes happen in -// `apl-cpex`, which has access to that machinery. -// -// Covers effects, PDP integration, and the PdpResolver seam. - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::evaluator::Decision; -use crate::pipeline::{TaintEvent, TaintScope}; -use crate::rules::Rule; - -/// Parser-internal intermediate IR. After the parser builds a Step -/// tree, `parser::step_to_top_level_effect` converts it into the -/// unified [`crate::rules::Effect`] used by the evaluator + every -/// public entry point. -/// -/// `Step` exists only because `parse_step` builds its nodes -/// incrementally and the conversion to `Effect::When` / -/// `Effect::Pdp` happens at the top of `compile_apl_blocks` once -/// the source position is known. Not part of the public API — -/// external code dispatches on `Effect` everywhere. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum Step { - /// Predicate-and-action rule. - Rule(Rule), - - /// External PDP call. `on_deny` / `on_allow` are reaction Step lists - /// that fire based on the PDP's decision. - Pdp { - call: PdpCall, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - on_deny: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - on_allow: Vec, - }, - - /// `plugin(name)` — invoke a CPEX-registered plugin. The plugin's - /// `PluginResult` decision becomes the step's outcome. - Plugin { name: String }, - - /// `delegate: { plugin: ..., ... }` — mint a downstream delegation - /// token via a TokenDelegateHook plugin. Populates - /// `delegation.granted_*` attributes in the bag so subsequent - /// rules in the same step list can read them. - Delegate(DelegateStep), - - /// `taint(label[, scope])` — apply a taint label. Always succeeds; - /// never produces a Deny. SessionStore dispatch happens in apl-cpex. - Taint { - label: String, - scopes: Vec, - }, - - /// `restrict: { ... }` — narrow the backend candidate set. Always - /// succeeds; never produces a Deny (accumulating, same family as - /// `Taint`). The evaluator collects the emitted constraint; a higher - /// layer (apl-cpex) folds it into a `CandidateConstraintExtension` - /// the host serializes to its router. - Restrict { - spec: crate::constraint::RestrictSpec, - }, - - /// `require_approval(...)` / `confirm(...)` / … — dispatch an - /// elicitation to a human and resume once resolved. The elicitation - /// analogue of `Delegate`; resolution is dispatched to an - /// `ElicitationHandler` plugin via apl-cpex. - Elicit(ElicitStep), -} - -/// One delegation invocation inside `pre_invocation:` or `post_invocation:`. -/// -/// At runtime the apl-cpex `DelegationInvoker` constructs a -/// `cpex_core::delegation::DelegationPayload` from -/// * the inbound bearer token (pulled from -/// `Extensions.raw_credentials.inbound_tokens`), -/// * this step's `args` (target / audience / permissions / mode / -/// attenuation, layered over the plugin's configured defaults), -/// * extensions-derived context (subject, prior delegation chain), -/// -/// then calls `manager.invoke_entries::(...)`. On -/// success the resulting `delegated_token` is written into -/// `Extensions.raw_credentials.delegated_tokens.*` and the granted -/// scopes / audience surface as `delegation.granted.*` attributes -/// in the policy bag for downstream rules to inspect. -/// -/// `args` is a free-form map because each delegation backend has its -/// own typed config shape; apl-core treats it as opaque and hands it -/// to the plugin via the existing per-call config-override pathway. -/// -/// # Multiple `delegate(...)` in one phase (most-recent-wins) -/// -/// Multiple `delegate(...)` steps in the same phase are supported — -/// each fires independently, each contributes to `Extensions` -/// (`raw_credentials.delegated_tokens` is a HashMap keyed on -/// audience+scope+mode so tokens accumulate; `delegation.chain` -/// grows with each hop). But the `delegation.granted.*` bag keys -/// are **overwritten** on each call — only the most recent -/// delegate's grants are queryable from downstream `require(...)` -/// rules. -/// -/// For fan-out flows that need multiple independently-queryable -/// grants, split into `pre_invocation:` + `post_invocation:`. There is -/// no per-step alias for naming an individual delegate's grants. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DelegateStep { - /// Plugin name — must reference an entry in the top-level - /// `plugins:` block that registers under the `token.delegate` - /// hook. - pub plugin_name: String, - - /// Per-call config overrides applied for this delegation only. - /// Layered on top of the plugin's default config; the framework's - /// `build_override_entries` plumbing handles the merge. - /// Common keys: `target`, `audience`, `permissions`, `mode`, - /// `attenuation`. Schema is plugin-defined. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_override: Option, - - /// `deny | continue` — what to do when the plugin returns a - /// deny (e.g. IdP refusal, network error). `None` defaults to - /// `"deny"` (fail-closed; matches PDP step semantics). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_error: Option, - - /// Human-readable source path (e.g. - /// `"route.get_compensation.policy[2]"`) — used in audit and - /// `Decision::Deny.rule_source` when the step denies. - pub source: String, -} - -/// The kind of elicitation — selects which validation contract the -/// runtime applies to the human's response. A single AST node -/// ([`Step::Elicit`]) covers every kind; the DSL exposes each via a -/// sugar verb (`require_approval` → `Approval`, `confirm` → `Confirm`, -/// …) that all parse to the same node. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ElicitKind { - /// Yes/no decision from a designated approver (manager approval). - /// The approver MAY differ from the request subject; the response is - /// bound to the request's args via `scope`. - Approval, - /// Cheap yes/no from the originating user ("yes, really do this"). - Confirm, - /// Re-auth / second factor by the originating user (fresh token, - /// elevated `acr`). - StepUp, - /// Signed statement from a designated party ("I confirm I reviewed X"). - Attestation, - /// Free-form clarification from the originating user. - Info, - /// Peer review of an action by a colleague. - Review, -} - -impl ElicitKind { - /// The snake_case wire name (matches the serde representation). Used - /// by the apl-cpex bridge to pass `kind` to channel plugins as a - /// string, since cpex-core can't depend on this enum. - pub fn as_str(&self) -> &'static str { - match self { - ElicitKind::Approval => "approval", - ElicitKind::Confirm => "confirm", - ElicitKind::StepUp => "step_up", - ElicitKind::Attestation => "attestation", - ElicitKind::Info => "info", - ElicitKind::Review => "review", - } - } -} - -/// One elicitation invocation inside `policy:` or `post_policy:` — the -/// runtime dispatches a question to a human (approval, confirmation, -/// step-up, …) through a channel plugin, holds a pending state across -/// the agent's retries, validates the response, and resumes. -/// -/// Structurally the elicitation analogue of [`DelegateStep`]: the DSL -/// carries the verb; apl-cpex dispatches resolution to the named -/// `ElicitationHandler` plugin (`plugin_name`, resolved exactly like -/// `delegate(...)`). The key -/// difference from delegation — which completes within one request — is -/// that an elicitation spans the gap between *dispatch* (the first -/// request that hits this step) and *resolution* (a later retry). That -/// gap is owned by the channel (e.g. Keycloak CIBA), never by a plugin -/// call: each of dispatch/check/validate is short and synchronous to the -/// request it runs in. -/// -/// # First arrival vs. retry -/// -/// On the first request that reaches this step, the runtime *dispatches* -/// the elicitation and the phase yields a pending entry (the host emits -/// JSON-RPC `-32120`). On a later retry carrying the elicitation id, the -/// runtime *checks* status and, once resolved, *validates* the response -/// against `scope` before the phase may proceed. -/// -/// `config_override` is a free-form map for channel-specific params -/// (e.g. CIBA `details_link`, Slack block-kit options); apl-core treats -/// it as opaque and hands it to the plugin via the same per-call -/// config-override pathway delegation uses. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ElicitStep { - /// Which elicitation contract applies (selects runtime validation). - pub kind: ElicitKind, - - /// Name of the `ElicitationHandler` plugin to invoke — the routing - /// key, resolved `name → entry` exactly like `delegate(...)` resolves - /// its plugin. The first positional argument of the sugar verb (e.g. - /// `require_approval(manager-approver, ...)`). Which backend it speaks - /// (CIBA / Slack / in-band) is the plugin's own opaque config, not - /// something apl-core interprets. - pub plugin_name: String, - - /// Optional channel label for audit/observability only (e.g. - /// `"ciba"`, `"slack"`). NOT a routing key — the framework never - /// dispatches on it. Surfaced into the bag as `elicitation.channel` - /// so the audit record can show how the human was reached. `None` - /// when the author doesn't declare one (a Phase 2 plugin may report - /// its own channel instead). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, - - /// Who is being asked — an attribute reference resolved against the - /// policy bag at dispatch (e.g. `"user.manager"`, `"user.sub"`). For - /// CIBA this becomes `login_hint`; the resolved identity is - /// cross-checked against the responder at `validate()`. - pub from: String, - - /// Canonical, human-readable description of what's being asked, with - /// request-arg substitution. Audited verbatim and shown to the - /// responder (CIBA `binding_message`) — the source of truth for - /// "what was approved," never an LLM summary. `None` for kinds that - /// carry their prompt elsewhere. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purpose: Option, - - /// APL boolean expression the runtime evaluates against the actual - /// request args at `validate()` to confirm the response covers what - /// was requested (e.g. `"args.amount <= 25000"`). This is the - /// args-binding layer — kept in APL because Keycloak does not support - /// RFC 9396 RAR. `None` for kinds without arg binding (e.g. a bare - /// `confirm`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, - - /// How long the elicitation stays valid before expiring (e.g. - /// `"24h"`). Surfaces as CIBA `requested_expiry`. `None` defers to - /// the channel plugin's configured default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout: Option, - - /// Per-call config overrides for channel-specific params, layered on - /// the plugin's default config. Opaque to apl-core. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_override: Option, - - /// `deny | continue` — what to do when dispatch or validation fails - /// (channel error, invalid response). `None` defaults to `"deny"` - /// (fail-closed; matches delegation/PDP step semantics). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_error: Option, - - /// Human-readable source path (e.g. - /// `"route.payroll_adjust.policy[0]"`) — used in audit and - /// `Decision::Deny.rule_source` when the step denies. - pub source: String, -} - -/// A PDP invocation, opaque-args style. Resolvers parse `args` based on -/// the dialect they handle — apl-core doesn't impose a Cedar/OPA/AuthZen -/// schema on `args`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PdpCall { - pub dialect: PdpDialect, - /// Dialect-specific call arguments — typically a map for Cedar - /// (`action`, `resource`, …) or a string for OPA/AuthZen/NeMo - /// (a path or query). Resolvers parse this; apl-core treats it - /// as opaque. - pub args: serde_yaml::Value, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum PdpDialect { - /// Bare Cedar policy evaluation (`cpex-pdp-cedar-direct`). - Cedar, - Opa, - AuthZen, - NeMo, - /// CEL (Common Expression Language) evaluation — `cpex-pdp-cel`. - /// The `cel:` step carries an `expr:` string that must evaluate to a - /// boolean against the policy `AttributeBag` (exposed to CEL as nested - /// namespaces: `subject.id`, `delegation.depth`, `session.labels`, …). - /// A small, safe, non-Turing-complete predicate language — distinct - /// from the full PDPs (Cedar/OPA) so all can coexist on one - /// `PdpRouter`. The canonical route-YAML form is the block map - /// `cel: { expr: "..." }`; the `cel:(...)` call form is also accepted. - Cel, - #[serde(untagged)] - Custom(String), -} - -impl PdpDialect { - /// Parse a YAML key prefix like `cedar`, `opa`, `authzen`, `nemo` - /// into the matching `PdpDialect`. Unknown dialects become `Custom`. - pub fn from_key(key: &str) -> Self { - match key { - "cedar" => Self::Cedar, - "opa" => Self::Opa, - "authzen" => Self::AuthZen, - "nemo" => Self::NeMo, - "cel" => Self::Cel, - other => Self::Custom(other.to_string()), - } - } -} - -/// External policy-decision dispatch. Implemented by Cedar, OPA HTTP -/// clients, AuthZen clients, NeMo Guardrails — anything that can answer -/// "given this call, allow or deny?" against a request context. -/// -/// `apl-cpex` provides the bridge from CPEX plugins (e.g. `cedar-direct`) -/// to this trait so the host doesn't have to know about the plugin types. -#[async_trait] -pub trait PdpResolver: Send + Sync { - /// What dialect this resolver handles. The evaluator routes PDP steps - /// to the resolver whose `dialect()` matches `Step::Pdp.call.dialect`. - fn dialect(&self) -> PdpDialect; - - async fn evaluate( - &self, - call: &PdpCall, - bag: &crate::attributes::AttributeBag, - ) -> Result; -} - -/// Build a [`PdpResolver`] from a unified-config block. Implemented per -/// PDP backend (cedar-direct, opa, …) and registered with -/// the apl-cpex visitor so unified-config YAML can declare PDPs -/// without the host pre-constructing them in code. -/// -/// Hosts register a factory by handing it to apl-cpex's -/// `AplOptions.pdp_factories`. When the visitor walks the unified -/// config and finds a `global.apl.pdp[].kind` matching the factory's -/// reported `kind()`, it calls `build` with the rest of that block. -/// -/// The error type is `Box` to keep this trait -/// in apl-core (which has no cpex deps). apl-cpex's visitor wraps -/// the boxed error into `VisitorError` → `PluginError::Config` at the -/// manager boundary. -pub trait PdpFactory: Send + Sync { - /// Identifies which `kind:` in a config block this factory handles. - /// Convention: kebab-case matching the published PDP product name - /// (`"cedar-direct"`, `"opa"`, …). - fn kind(&self) -> &str; - - /// Build a resolver from the rest of the PDP config block (everything - /// under the same map level as `kind`). Implementations parse their - /// own config shape; missing or malformed fields surface here. - fn build( - &self, - config: &serde_yaml::Value, - ) -> Result, Box>; -} - -/// Where in the request lifecycle a plugin dispatch is happening. -/// Threads through `PluginInvocation` so the invoker can select the -/// right hook entry from a plugin that registered for both pre and -/// post phases (e.g. `cmf.tool_pre_invoke` AND `cmf.tool_post_invoke`). -/// -/// APL's four phases map to two dispatch phases: -/// * `args:` field stages → `Pre` -/// * `pre_invocation:` steps → `Pre` -/// * `result:` field stages → `Post` -/// * `post_invocation:` steps → `Post` -/// -/// Plugins that need to discriminate `args` vs `policy` (same `Pre` -/// from the dispatcher's perspective) inspect `PluginContext::hook_name()` -/// inside their handler — the hook-routing layer doesn't slice phase -/// finer than Pre/Post. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DispatchPhase { - Pre, - Post, -} - -/// Context for one plugin invocation: tells the invoker the *intent* of -/// the call so it can dispatch to the right CPEX hook contract. -/// -/// `Step` is the policy / post_policy case — the invoker (apl-cpex side) -/// already holds a typed payload reference; APL doesn't need to pass one. -/// -/// `Field` is the pipe-chain case — APL is focused on a specific field -/// value mid-transform and the plugin may rewrite that value via -/// `PluginOutcome.modified_value`. -/// -/// Both variants carry a `DispatchPhase` so the invoker can resolve the -/// right hook entry against the cpex-core hook routing table when the -/// plugin registered for multiple hooks. -#[derive(Debug, Clone, Copy)] -pub enum PluginInvocation<'a> { - /// Called from a `pre_invocation:` or `post_invocation:` step. The plugin operates - /// on whatever typed payload the invoker was bound to. - Step { phase: DispatchPhase }, - /// Called inside an `args:` / `result:` pipe chain on one field. - Field { - /// Dotted path to the field, relative to the args or result root - /// — `city`, `user.ssn`, never `args.city`. The phase says which - /// root it hangs off: Pre addresses args, Post addresses result. - /// Every call site uses this convention, so an invoker can read - /// the field back out of a payload without guessing. - name: &'a str, - value: &'a serde_json::Value, - phase: DispatchPhase, - }, -} - -impl<'a> PluginInvocation<'a> { - /// Convenience: the dispatch phase carried by this invocation. - pub fn phase(&self) -> DispatchPhase { - match self { - PluginInvocation::Step { phase } => *phase, - PluginInvocation::Field { phase, .. } => *phase, - } - } -} - -/// Plugin invocation dispatch. apl-cpex wraps the CPEX `PluginManager` -/// behind this trait so the apl-core evaluator stays free of cpex-core -/// dependencies. -#[async_trait] -pub trait PluginInvoker: Send + Sync { - /// Invoke the named plugin against the current request context. The - /// `invocation` discriminates step vs pipe-chain call. - async fn invoke( - &self, - name: &str, - bag: &crate::attributes::AttributeBag, - invocation: PluginInvocation<'_>, - ) -> Result; -} - -/// Delegation dispatch — invokes a `TokenDelegateHook` plugin to mint -/// a downstream credential. apl-cpex implements this against -/// `cpex_core::PluginManager::invoke_entries::`. -/// -/// The invoker holds the request-scoped `Extensions` internally -/// (same pattern as `CmfPluginInvoker`), so the trait method doesn't -/// need to pass them — the invoker uses its own snapshot to construct -/// the `DelegationPayload` (inbound bearer token, subject, prior -/// delegation chain). -#[async_trait] -pub trait DelegationInvoker: Send + Sync { - /// Run one delegation step. Returns a `DelegationOutcome` carrying - /// the granted permissions / audience / expiry the IdP issued; the - /// evaluator writes those into the bag as `delegation.granted_*` - /// attributes so subsequent rules in the same step list can - /// inspect them via `require(delegation.granted_permissions - /// contains "X")` etc. - /// - /// `step.config_override` is layered on top of the plugin's - /// default config and threaded through the standard per-call - /// override pathway. - async fn delegate(&self, step: &DelegateStep) -> Result; -} - -/// What a delegation invocation returned. -/// -/// On success, `decision` is `Allow` and the granted_* fields reflect -/// what the IdP actually issued (which may be narrower than what the -/// route asked for — `granted_permissions` is the source of truth for -/// what the downstream tool will accept). The evaluator surfaces these -/// into the bag under the `delegation.granted.*` sub-namespace plus a -/// `delegation.granted = true` flag. -/// -/// On `Deny`, granted_* fields are empty / `None` and the -/// `delegation.granted` flag is not set (absent → falsy). -#[derive(Debug, Clone)] -pub struct DelegationOutcome { - pub decision: Decision, - /// Permissions the IdP actually granted on the minted token. Empty - /// when the call failed or the plugin returned no token. - pub granted_permissions: Vec, - /// Audience the minted token is valid for. `None` when no token - /// was produced. - pub granted_audience: Option, - /// Token expiry (RFC 3339 string for bag-friendly representation). - /// `None` when no token was produced. - pub granted_expires_at: Option, -} - -impl DelegationOutcome { - /// Convenience for the "deny, nothing granted" case. - pub fn deny(decision: Decision) -> Self { - Self { - decision, - granted_permissions: Vec::new(), - granted_audience: None, - granted_expires_at: None, - } - } -} - -#[derive(Debug, Error)] -pub enum DelegationError { - #[error("no delegation invoker available for plugin `{0}`")] - NotFound(String), - - #[error("delegation dispatch failed: {0}")] - Dispatch(String), - - /// A delegation step key was present but held an invalid value — a - /// typo'd `subject:` / `actor:`, say. Distinct from an absent key - /// (whose documented default applies): a present-but-wrong value must - /// fail rather than silently exchange a different credential shape. - #[error("invalid delegation config: {0}")] - InvalidConfig(String), -} - -/// `DelegationInvoker` impl that returns `NotFound` for every call. -/// Useful as the default for evaluator callers that don't run any -/// `delegate(...)` steps — they need to pass *something* implementing -/// the trait, but the noop never actually gets invoked. Tests and -/// hosts that haven't wired a real delegation backend pass this. -pub struct NoopDelegationInvoker; - -#[async_trait] -impl DelegationInvoker for NoopDelegationInvoker { - async fn delegate(&self, step: &DelegateStep) -> Result { - Err(DelegationError::NotFound(step.plugin_name.clone())) - } -} - -/// Elicitation dispatch — drives a human-in-the-loop step (approval, -/// confirmation, step-up, …) through a channel plugin. apl-cpex -/// implements this against the named `ElicitationHandler` plugin -/// (`step.plugin_name`, resolved `name → entry` like delegation); tests -/// and un-wired hosts pass [`NoopElicitationInvoker`]. -/// -/// Three short, synchronous touchpoints span the human's (possibly -/// hours-long) decision. The wait itself lives in the channel (e.g. -/// Keycloak CIBA), never inside a trait call: -/// -/// * [`dispatch`](ElicitationInvoker::dispatch) — once, on the first -/// request that reaches the step: register the intent, open the -/// backchannel, and return the id the agent echoes on retry. -/// * [`check`](ElicitationInvoker::check) — on every retry: read the -/// current status (pending / resolved / expired) without blocking. -/// * [`validate`](ElicitationInvoker::validate) — once status is -/// resolved: confirm the response is *genuine* (signature, intent -/// binding, responder identity). The *sufficiency* check — -/// [`ElicitStep::scope`] against the live request args — is the -/// runtime's job, not the plugin's, because `scope` is an APL -/// expression the plugin cannot evaluate. -/// -/// Like [`DelegationInvoker`], the invoker holds the request-scoped -/// `Extensions` internally, so the trait methods take only the step / id -/// and never the request context. -#[async_trait] -pub trait ElicitationInvoker: Send + Sync { - /// First arrival. Register the intent and open the channel - /// backchannel for `step`, returning the correlation id plus the - /// pending metadata the evaluator writes into the bag - /// (`elicitation.id` / `.approver` / `.intent_id`). Short and - /// synchronous — the human's decision happens *after* this returns, - /// inside the channel. - /// - /// `resolved_from` is `step.from` already resolved against the request - /// bag by the runtime (e.g. `claim.manager` → the manager's actual - /// identity), or the literal `step.from` when it isn't a bag key. The - /// attribute vocabulary lives in the runtime, so the invoker receives - /// the resolved identity rather than re-resolving it — for CIBA this - /// becomes the `login_hint`. - async fn dispatch( - &self, - step: &ElicitStep, - resolved_from: &str, - ) -> Result; - - /// Retry. Read the current status of a dispatched elicitation by - /// `id` without blocking — `Pending` until the human acts, then - /// `Resolved` (carrying approved/denied) or `Expired`. `step` is - /// passed (the same step that dispatched) so the invoker can resolve - /// which handler plugin owns this elicitation — on a retry only the - /// id is in the bag, but the step is still in scope. - async fn check( - &self, - step: &ElicitStep, - id: &str, - ) -> Result; - - /// Resolution. Verify that the resolved response is *genuine* — the - /// signed token validates, its intent binding matches this `id`, and - /// the responder is the resolved approver. Returns the verdict plus - /// the facts the evaluator records for audit. The runtime applies the - /// `scope`-over-args check separately before honoring an approval. - /// `step` resolves the owning handler plugin (see [`check`]). - /// - /// [`check`]: ElicitationInvoker::check - async fn validate( - &self, - step: &ElicitStep, - id: &str, - ) -> Result; -} - -/// What [`ElicitationInvoker::dispatch`] returns — the correlation id -/// plus the pending metadata the evaluator surfaces into the bag -/// (`elicitation.*`) and the host echoes in the JSON-RPC `-32120` -/// pending entry. -#[derive(Debug, Clone)] -pub struct ElicitationDispatch { - /// Server-side id the agent echoes on retry. Keys the - /// `{requester, args, scope, original_request_id}` record. - pub id: String, - /// Resolved approver identity (the `from` attr resolved at dispatch, - /// e.g. the manager's `sub`). `None` when the channel resolves the - /// responder only at validation time. Surfaced as - /// `elicitation.approver`. - pub approver: Option, - /// Registered intent id (lodging-intent binding) when the channel - /// supports it. Surfaced as `elicitation.intent_id`. - pub intent_id: Option, - /// When the elicitation expires (RFC 3339). `None` defers to the - /// channel plugin's configured default. - pub expires_at: Option, -} - -/// Current state of a dispatched elicitation, read by -/// [`ElicitationInvoker::check`] on each retry. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ElicitationStatus { - /// The human has not responded yet — the phase stays pending and the - /// host re-emits `-32120`. - Pending, - /// The human responded. `outcome` carries approved/denied; the - /// runtime still calls `validate` before honoring an `Approved`. - Resolved { outcome: ElicitationOutcome }, - /// The elicitation timed out before a response — the runtime fails - /// closed (subject to the step's `on_error`). - Expired, -} - -/// The human's decision once an elicitation resolves. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ElicitationOutcome { - Approved, - Denied, -} - -/// What [`ElicitationInvoker::validate`] returns — the *genuineness* -/// verdict plus the resolved facts the runtime records for audit. The -/// runtime layers the `scope`-over-args check on top before allowing the -/// phase to proceed. -#[derive(Debug, Clone)] -pub struct ElicitationValidation { - /// `true` when the response is genuine: the signed token validates, - /// its intent binding matches this elicitation, and the responder is - /// the resolved approver. - pub valid: bool, - /// Who actually consented — cross-checked against the dispatch-time - /// approver. Recorded as `elicitation.approver`. - pub approver: Option, - /// Intent id carried in the signed response, for audit - /// reconciliation against the registered intent. - pub intent_id: Option, - /// Why validation failed, when `valid` is `false`. `None` on success. - pub reason: Option, -} - -/// The "ask again later" bundle — produced when an elicitation has been -/// dispatched but the human hasn't responded yet. It carries everything -/// the host needs to emit a JSON-RPC `-32120` ("request not complete, -/// retry echoing this id") to the agent instead of forwarding the call. -/// -/// This is the tri-state channel that lets `Decision` stay binary: a -/// suspended phase reports `Decision::Allow` (nothing was *denied*) with a -/// `Some(PendingElicitation)` alongside it. The host rule is one clause — -/// **forward iff `Allow` AND `pending.is_none()`**; otherwise emit -/// `-32120`. The agent re-sends with `elicitation.id`, the runtime takes -/// the "id present → check, don't re-dispatch" path, and once the human -/// resolves, the phase proceeds past the elicitation. -/// -/// Pending **short-circuits** the phase (sequential elicitation): at most -/// one pending per pass. Multiple concurrent pendings are deferred (would -/// turn this into a `Vec` on `StepsEvaluation`). -#[derive(Debug, Clone, PartialEq)] -pub struct PendingElicitation { - /// Server-side id the agent echoes on retry (`elicitation.id`). - pub id: String, - /// Which `ElicitationHandler` plugin owns this elicitation. - pub plugin_name: String, - /// Resolved approver identity, when known at dispatch. - pub approver: Option, - /// Registered intent id (lodging-intent binding), when the channel - /// supports it. - pub intent_id: Option, - /// Optional channel label for the agent-facing `-32120` / audit. - pub channel: Option, - /// When the elicitation expires (RFC 3339), when known. - pub expires_at: Option, - /// Rule source path of the originating `Elicit` step, for audit. - pub source: String, -} - -#[derive(Debug, Error)] -pub enum ElicitationError { - #[error("no elicitation invoker available for plugin `{0}`")] - NotFound(String), - - /// The handler failed to service an operation (dispatch / check / - /// validate) — a channel error, a handler deny, or a malformed - /// response. The message names the operation; the evaluator routes - /// this through the step's `on_error`. - #[error("elicitation handler error: {0}")] - Handler(String), -} - -/// [`ElicitationInvoker`] impl that returns `NotFound` for every call. -/// The default for evaluator callers that run no elicitation steps — -/// they must pass *something* implementing the trait, but the noop never -/// actually gets invoked. Mirrors [`NoopDelegationInvoker`]; tests and -/// hosts that haven't wired a real channel backend pass this. -pub struct NoopElicitationInvoker; - -#[async_trait] -impl ElicitationInvoker for NoopElicitationInvoker { - async fn dispatch( - &self, - step: &ElicitStep, - _resolved_from: &str, - ) -> Result { - Err(ElicitationError::NotFound(step.plugin_name.clone())) - } - - async fn check( - &self, - _step: &ElicitStep, - id: &str, - ) -> Result { - Err(ElicitationError::NotFound(id.to_string())) - } - - async fn validate( - &self, - _step: &ElicitStep, - id: &str, - ) -> Result { - Err(ElicitationError::NotFound(id.to_string())) - } -} - -/// `ElicitationInvoker` that immediately approves every elicitation: -/// `dispatch` returns a synthetic id (echoing the requested `from` as the -/// resolved approver), `check` reports `Resolved { Approved }` on the -/// first pass, and `validate` returns a genuine verdict. This lets a -/// single request flow dispatch → check → validate → allow without a real -/// channel — for evaluator tests and offline demos. -/// -/// NOT for production: it makes no actual approval decision. Hosts wire a -/// real channel invoker (e.g. the apl-cpex `ElicitationHandler` bridge). -#[derive(Default)] -pub struct AutoApprovingElicitor; - -#[async_trait] -impl ElicitationInvoker for AutoApprovingElicitor { - async fn dispatch( - &self, - step: &ElicitStep, - resolved_from: &str, - ) -> Result { - Ok(ElicitationDispatch { - id: format!("auto-{}", step.plugin_name), - // Echo the *resolved* approver, as a real channel would. - approver: Some(resolved_from.to_string()), - intent_id: Some("auto-intent".to_string()), - expires_at: None, - }) - } - - async fn check( - &self, - _step: &ElicitStep, - _id: &str, - ) -> Result { - Ok(ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Approved, - }) - } - - async fn validate( - &self, - _step: &ElicitStep, - _id: &str, - ) -> Result { - Ok(ElicitationValidation { - valid: true, - // Leave approver/intent unset — the dispatch-time values - // already recorded in the bag stand. - approver: None, - intent_id: Some("auto-intent".to_string()), - reason: None, - }) - } -} - -/// What a PDP returned. -#[derive(Debug, Clone, PartialEq)] -pub struct PdpDecision { - pub decision: Decision, - /// Optional diagnostic info: matched policy IDs, error codes, etc. - /// Surfaces in audit logs; not used for control flow. - pub diagnostics: Vec, -} - -/// What a plugin returned. -#[derive(Debug, Clone)] -pub struct PluginOutcome { - pub decision: Decision, - /// Plugins may apply taint labels as a side effect. Same shape as - /// config-emitted taints (`Step::Taint` / `Stage::Taint`) so the - /// downstream evaluator can append both into a single - /// `Vec` without converting. Each event may carry - /// multiple scopes — `CmfPluginInvoker` uses single-scope - /// (`Session`) for v0 but future invokers and plugins that emit - /// directly are free to span scopes. - pub taints: Vec, - /// Pipe-context return: when a plugin runs as a stage inside an - /// args/result chain, it may rewrite the field value (e.g., a PII - /// scrubber producing a redacted string). `None` means "leave value - /// unchanged"; always `None` for policy / post_policy invocations. - /// - /// Scoped to the field named in [`PluginInvocation::Field`] and - /// nothing else. A plugin that rewrote some other part of the - /// payload reports `None` here — that mutation travels with the - /// payload instead, so it isn't lost. - pub modified_value: Option, -} - -impl PluginOutcome { - /// Convenience for the common "allow, no taints, no value change" case. - pub fn allow() -> Self { - Self { - decision: Decision::Allow, - taints: vec![], - modified_value: None, - } - } -} - -#[derive(Debug, Error)] -pub enum PdpError { - #[error("no PDP resolver registered for dialect {0:?}")] - NoResolver(PdpDialect), - - #[error("PDP dispatch failed: {0}")] - Dispatch(String), -} - -#[derive(Debug, Error)] -pub enum PluginError { - #[error("no plugin invoker available for `{0}`")] - NotFound(String), - - #[error("plugin dispatch failed: {0}")] - Dispatch(String), -} - -impl Step { - /// Wrap a `Rule` as a `Step`. Saves typing in tests and parser code. - pub fn rule(r: Rule) -> Self { - Step::Rule(r) - } - - /// Returns true if this step is a plain rule (no async dispatch needed). - pub fn is_rule(&self) -> bool { - matches!(self, Step::Rule(_)) - } -} - -/// Bag keys the delegation step writes after a successful dispatch. -/// Centralized here so the evaluator (writer) and policy authors -/// (readers, via `require(delegation.granted.*)`) agree on the -/// canonical names — typos in either place silently break the -/// IdP-as-PDP pattern. -/// -/// # Namespace -/// -/// The `delegation.*` namespace at the top level carries INBOUND -/// chain attributes (`delegation.depth`, `delegation.origin`, -/// `delegation.chain`, ...) populated by identity resolver plugins -/// via `IdentityPayload.delegation` + apply-to-extensions, then -/// surfaced through apl-cmf's BagBuilder. -/// -/// The `delegation.granted.*` sub-namespace defined here is for -/// OUTBOUND results — what came back from a `delegate(...)` step -/// the framework just ran. Two writers (identity plugin for inbound, -/// `delegate(...)` for outbound), distinct sub-trees, no collision. -pub mod delegation_bag_keys { - /// `StringSet` — permissions actually granted by the IdP on the - /// minted token. May be narrower than `required_permissions`. - pub const GRANTED_PERMISSIONS: &str = "delegation.granted.permissions"; - /// `String` — audience the minted token is valid for. - pub const GRANTED_AUDIENCE: &str = "delegation.granted.audience"; - /// `String` — token expiry as RFC 3339. - pub const GRANTED_EXPIRES_AT: &str = "delegation.granted.expires_at"; - /// `Bool` — set to `true` after a successful `delegate(...)` - /// step. Lets policy branch on success without inspecting the - /// granted_permissions set: `require(delegation.granted)`. Absent - /// (i.e. evaluates to false) when no delegate step has run OR - /// when the most recent one denied. - pub const GRANTED: &str = "delegation.granted"; -} - -/// Bag keys an elicitation step writes so downstream rules in the same -/// phase — and the audit plugin — can read its state. Centralized here -/// (like [`delegation_bag_keys`]) so the evaluator/invoker (writers) and -/// policy authors (readers, via `require(elicitation.*)`) agree on the -/// canonical names. -/// -/// On *dispatch* the runtime writes `id` + `status = "pending"` (plus -/// `approver` / `intent_id` when known). On *resolution* it updates -/// `status` and sets `outcome`. A phase with a pending elicitation does -/// not forward. -pub mod elicitation_bag_keys { - /// `String` — the elicitation id the agent echoes on retry. Server-side - /// key into `{requester, args, scope, original_request_id}`. - pub const ID: &str = "elicitation.id"; - /// `String` — `pending | resolved | expired`. - pub const STATUS: &str = "elicitation.status"; - /// `String` — resolved approver identity, cross-checked against `from`. - pub const APPROVER: &str = "elicitation.approver"; - /// `String` — `approved | denied` once resolved. - pub const OUTCOME: &str = "elicitation.outcome"; - /// `String` — registered intent id (lodging-intent binding), echoed in - /// the OP-signed token for `validate()` and audit reconciliation. - pub const INTENT_ID: &str = "elicitation.intent_id"; - /// `String` — optional channel label (`ciba` / `slack` / …) for - /// audit/observability. Not a routing key. - pub const CHANNEL: &str = "elicitation.channel"; - /// `String` — when the elicitation expires (RFC 3339), when the - /// channel reported one at dispatch. - pub const EXPIRES_AT: &str = "elicitation.expires_at"; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn from_key_maps_known_dialects() { - assert_eq!(PdpDialect::from_key("cedar"), PdpDialect::Cedar); - assert_eq!(PdpDialect::from_key("opa"), PdpDialect::Opa); - assert_eq!(PdpDialect::from_key("authzen"), PdpDialect::AuthZen); - assert_eq!(PdpDialect::from_key("nemo"), PdpDialect::NeMo); - assert_eq!(PdpDialect::from_key("cel"), PdpDialect::Cel); - } - - #[test] - fn from_key_unknown_is_custom() { - assert_eq!( - PdpDialect::from_key("rego-remote"), - PdpDialect::Custom("rego-remote".to_string()) - ); - } - - #[tokio::test] - async fn noop_elicitation_invoker_is_not_found_for_every_method() { - // The noop must never silently succeed — every method reports - // NotFound so an un-wired host fails closed rather than treating - // an elicitation step as approved. - let inv = NoopElicitationInvoker; - let step = ElicitStep { - kind: ElicitKind::Approval, - plugin_name: "manager-approver".to_string(), - channel: Some("ciba".to_string()), - from: "user.manager".to_string(), - purpose: None, - scope: None, - timeout: None, - config_override: None, - on_error: None, - source: "route.test.policy[0]".to_string(), - }; - - let d = inv.dispatch(&step, "alice@example.com").await; - assert!(matches!(d, Err(ElicitationError::NotFound(c)) if c == "manager-approver")); - - let c = inv.check(&step, "elic-123").await; - assert!(matches!(c, Err(ElicitationError::NotFound(id)) if id == "elic-123")); - - let v = inv.validate(&step, "elic-123").await; - assert!(matches!(v, Err(ElicitationError::NotFound(id)) if id == "elic-123")); - } - - #[test] - fn elicitation_status_resolved_carries_outcome() { - // Resolved is distinct from its outcome — a denied resolution is - // still "resolved" (the runtime stops retrying) but must not be - // confused with Pending/Expired. - let approved = ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Approved, - }; - let denied = ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Denied, - }; - assert_ne!(approved, denied); - assert_ne!(approved, ElicitationStatus::Pending); - assert_ne!(denied, ElicitationStatus::Expired); - } - - #[test] - fn cel_dialect_serde_roundtrips_as_snake_case() { - // `Cel` is a tagged variant (snake_case) — must round-trip so - // compiled-route serialization (audit/cache) preserves it. - let json = serde_json::to_string(&PdpDialect::Cel).unwrap(); - assert_eq!(json, "\"cel\""); - let back: PdpDialect = serde_json::from_str(&json).unwrap(); - assert_eq!(back, PdpDialect::Cel); - } -} diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs deleted file mode 100644 index 53aa5a83..00000000 --- a/crates/apl-core/tests/yaml_end_to_end.rs +++ /dev/null @@ -1,339 +0,0 @@ -// Location: ./crates/apl-core/tests/yaml_end_to_end.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end integration: YAML config → compiled IR → evaluated against a -// realistic AttributeBag and payload. This exercises the public crate API -// only (`compile_config` + `evaluate_route` + traits) and serves as the -// authoritative "if this passes, apl-core works as a unit" check. -// -// The fixture is a representative HR route, adapted to -// the map-keyed `routes:` shape that the parser actually accepts (the -// list-with-matchers form is a deferred shape). - -use std::sync::Arc; - -use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, ElicitationInvoker, - FieldOutcome, NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, - PdpError, PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, - RoutePayload, -}; -use async_trait::async_trait; -use serde_json::json; - -// Test fixtures: every scenario passes the same no-op plugin invoker and -// no-op delegation invoker, so wrap them once in the `Arc` shape -// `evaluate_route` expects and let each call borrow. -fn pdp() -> Arc { - Arc::new(AllowPdp) -} -fn plugins() -> Arc { - Arc::new(NoPlugins) -} -fn delegations() -> Arc { - Arc::new(NoopDelegationInvoker) -} -fn elicitations() -> Arc { - Arc::new(NoopElicitationInvoker) -} - -// ----- Fixtures: a baseline route used by every scenario below. ----- - -const HR_ROUTE_YAML: &str = r#" -routes: - get_employee: - args: - employee_id: "str" - pre_invocation: - - "require(authenticated)" - - "delegation.depth > 2: deny" - result: - ssn: "str | redact(!perm.view_ssn)" - salary: "int | redact(!role.hr)" - employee_id: "str | mask(4)" -"#; - -struct AllowPdp; -#[async_trait] -impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }) - } -} - -struct NoPlugins; -#[async_trait] -impl PluginInvoker for NoPlugins { - async fn invoke( - &self, - name: &str, - _bag: &AttributeBag, - _invocation: PluginInvocation<'_>, - ) -> Result { - Err(PluginError::NotFound(name.into())) - } -} - -// ----- Scenarios ----- - -#[tokio::test] -async fn alice_full_access_sees_unredacted_result_with_masked_id() { - // Alice: authenticated HR with view_ssn permission, depth=1. - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("role.hr", true); - bag.set("perm.view_ssn", true); - bag.set("delegation.depth", 1_i64); - - let routes = compile_config(HR_ROUTE_YAML).expect("YAML compiles").routes; - let route = routes.get("get_employee").expect("route present"); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "123-45-6789" }), - json!({ - "ssn": "555-12-3456", - "salary": 95000, - "employee_id": "123-45-6789", - }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!( - r.args_modified == false, - "args has only a `str` validator, no mutation" - ); - assert!(r.result_modified, "result has mask + redact stages"); - - let result = payload.result.as_ref().unwrap(); - // view_ssn=true → redact(!view_ssn) skipped → ssn intact. - assert_eq!(result["ssn"], json!("555-12-3456")); - // role.hr=true → redact(!role.hr) skipped → salary intact. - assert_eq!(result["salary"], json!(95000)); - // mask(4) always applies → keeps last 4 chars. - assert_eq!(result["employee_id"], json!("*******6789")); -} - -#[tokio::test] -async fn mallory_no_perm_no_role_gets_both_fields_redacted() { - // Mallory: authenticated but no role, no perm, shallow delegation. - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("delegation.depth", 1_i64); - // role.hr and perm.view_ssn are absent → IsTrue=false → !IsTrue=true → redact fires. - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "555-44-3333" }), - json!({ - "ssn": "111-22-3333", - "salary": 80000, - "employee_id": "555-44-3333", - }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - - let result = payload.result.as_ref().unwrap(); - assert_eq!(result["ssn"], json!("[REDACTED]")); - assert_eq!(result["salary"], json!("[REDACTED]")); - assert_eq!(result["employee_id"], json!("*******3333")); -} - -#[tokio::test] -async fn deep_delegation_denies_at_policy() { - // Authenticated user but delegation.depth=3 > 2 → policy deny. - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("role.hr", true); - bag.set("perm.view_ssn", true); - bag.set("delegation.depth", 3_i64); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "123-45-6789" }), - json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("pre_invocation"), - "got source: {}", - rule_source - ); - }, - d => panic!("expected policy deny, got {:?}", d), - } - // Result phase never ran → no result mutation. - assert!(!r.result_modified); - assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("x")); - assert_eq!( - payload.result.as_ref().unwrap()["employee_id"], - json!("123-45-6789") - ); -} - -#[tokio::test] -async fn unauthenticated_user_is_denied_before_args_mutate_result() { - // No `authenticated` key → require(authenticated) fails → deny. - let mut bag = AttributeBag::new(); - bag.contains("authenticated"); // sanity: confirm we built an empty bag. - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": "123-45-6789" }), - json!({ "ssn": "999-99-9999", "salary": 50000, "employee_id": "123-45-6789" }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert!(matches!(r.decision, Decision::Deny { .. })); - assert!(!r.result_modified); -} - -#[tokio::test] -async fn args_validator_rejects_wrong_type() { - // args.employee_id is declared `str` — an integer value violates that - // and should produce a deny during the args phase, before policy runs. - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("delegation.depth", 1_i64); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::with_result( - json!({ "employee_id": 42 }), // ← wrong type - json!({ "ssn": "x", "salary": 1, "employee_id": "x" }), - ); - - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - match r.decision { - Decision::Deny { rule_source, .. } => { - assert!( - rule_source.contains("employee_id"), - "expected args field source, got {}", - rule_source, - ); - }, - d => panic!("expected args-phase deny, got {:?}", d), - } - // Result phase didn't run. - assert!(!r.result_modified); -} - -#[tokio::test] -async fn inbound_only_evaluation_skips_result_phase() { - // Simulates the inbound path: payload has no result yet. Args + policy - // run; result phase is skipped; post_policy runs (none defined here). - let mut bag = AttributeBag::new(); - bag.set("authenticated", true); - bag.set("delegation.depth", 1_i64); - - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - - let mut payload = RoutePayload::new(json!({ "employee_id": "123-45-6789" })); - let r = evaluate_route( - route, - &mut bag, - &mut payload, - &pdp(), - &plugins(), - &delegations(), - &elicitations(), - ) - .await; - assert_eq!(r.decision, Decision::Allow); - assert!(!r.result_modified); - assert!(payload.result.is_none()); - // Args field is untouched — `str` is validator-only, no transform. - assert_eq!(payload.args["employee_id"], json!("123-45-6789")); -} - -// ----- Smoke test: phase-existence reporting matches what's in the YAML. ----- - -#[test] -fn compiled_route_phase_set_reflects_yaml_blocks() { - use apl_core::Phase; - let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; - let route = routes.get("get_employee").unwrap(); - let phases = route.declared_phases(); - assert!(phases.contains(Phase::Args)); - assert!(phases.contains(Phase::Policy)); - assert!(phases.contains(Phase::Result)); - assert!(!phases.contains(Phase::PostPolicy)); -} - -// Marker so the file isn't all `_` — sanity check that `FieldOutcome` is -// reachable as part of the public surface alongside the orchestrator's -// `RouteDecision`. Removing this when downstream consumers exist. -#[test] -fn public_surface_includes_field_outcome() { - let _: FieldOutcome = FieldOutcome::Pass; -} diff --git a/crates/apl-cpex/Cargo.toml b/crates/apl-cpex/Cargo.toml deleted file mode 100644 index e8998ce8..00000000 --- a/crates/apl-cpex/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -# Location: ./crates/apl-cpex/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# apl-cpex — the bridge between APL's evaluator (`apl-core`) and CPEX's -# runtime (`cpex-core`). Provides per-hook-type implementations of -# `apl-core::PluginInvoker` that translate APL plugin dispatch into typed -# `cpex-core::PluginManager::invoke_named::` calls. -# -# Design constraints inherited from `apl-core`: -# - `apl-core` has zero CPEX deps; cross-crate boundary lives here. -# - The PluginInvoker trait is string-typed; the typed boundary lives -# INSIDE each impl (one impl per HookTypeDef, e.g. CmfPluginInvoker -# for CMF, future DelegationPluginInvoker for delegation hooks). -# - Payload is built ONCE by the host and threaded through the invoker -# for the full request lifetime — never reconstructed from the bag. - -[package] -name = "apl-cpex" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "APL ↔ CPEX runtime bridge — per-hook PluginInvoker implementations." -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -apl-core = { workspace = true } -apl-cmf = { workspace = true } -cpex-core = { workspace = true } -async-trait = { workspace = true } -chrono = { workspace = true } -thiserror = { workspace = true } -serde_json = { workspace = true } -serde_yaml = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -# Stable hash for tier-3 (identity-derived) session id. `DefaultHasher` -# 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.11" - -[dev-dependencies] -serde = { workspace = true } - -[lints] -workspace = true diff --git a/crates/apl-cpex/src/attribute_source.rs b/crates/apl-cpex/src/attribute_source.rs deleted file mode 100644 index 918c9811..00000000 --- a/crates/apl-cpex/src/attribute_source.rs +++ /dev/null @@ -1,306 +0,0 @@ -// Location: ./crates/apl-cpex/src/attribute_source.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// FileAttributeSource — the built-in `data.*` provider. -// -// Reads a list of attribute files (YAML, each wrapping everything under a -// top-level `data:` mapping) and deep-merges them into one -// `AttributeTree`. Different subtrees combine freely; a genuine same-leaf -// conflict (two files setting the same path to different values) is a -// **load-time error**, not a silent last-wins clobber. Snapshot only in -// v1 (no `watch`); hot-reload is deferred. -// -// `AttributeSource::load` is synchronous (it runs once at startup), so -// this is just plain file I/O plus the merge — no async, no runtime. - -use std::path::{Path, PathBuf}; - -use serde_json::{Map, Value}; - -use apl_core::attribute_source::{AttributeError, AttributeSource, AttributeTree}; - -/// Built-in file-backed [`AttributeSource`]. Construct from the paths in -/// `settings.attribute_files`; each is read and deep-merged, in order, -/// into the `data.*` tree. -pub struct FileAttributeSource { - paths: Vec, -} - -impl FileAttributeSource { - /// New source over the given files (merged in order). - pub fn new(paths: impl IntoIterator) -> Self { - Self { - paths: paths.into_iter().collect(), - } - } -} - -impl AttributeSource for FileAttributeSource { - /// Read and merge the files. Synchronous — runs at startup, off the - /// request path. - fn load(&self) -> Result { - let mut docs = Vec::with_capacity(self.paths.len()); - for path in &self.paths { - let text = std::fs::read_to_string(path) - .map_err(|e| AttributeError::Load(format!("{}: {}", path.display(), e)))?; - let doc: Value = serde_yaml::from_str(&text) - .map_err(|e| AttributeError::Parse(format!("{}: {}", path.display(), e)))?; - docs.push((label(path), doc)); - } - merge_attribute_docs(docs) - } -} - -fn label(path: &Path) -> String { - path.display().to_string() -} - -/// Deep-merge a sequence of parsed attribute documents into one tree. -/// Each `doc` is a whole file's parsed YAML (`{ data: { ... } }`); the -/// `String` is a source label used in error messages. Pure and -/// in-memory-testable — [`FileAttributeSource::load`] just reads the -/// files first. -pub fn merge_attribute_docs(docs: I) -> Result -where - I: IntoIterator, -{ - let mut acc: Map = Map::new(); - for (label, doc) in docs { - let data = extract_data(&label, doc)?; - merge_object(&mut acc, data, "data")?; - } - Ok(AttributeTree::new(Value::Object(acc))) -} - -/// Pull the `data:` mapping out of one file's parsed document, enforcing -/// the "everything lives under `data:`" contract. An empty file is fine; -/// stray top-level keys (a forgotten `data:` wrapper) are a hard error. -fn extract_data(label: &str, doc: Value) -> Result, AttributeError> { - match doc { - Value::Null => Ok(Map::new()), - Value::Object(mut m) => { - let data = m.remove("data"); - if !m.is_empty() { - let mut stray: Vec = m.keys().cloned().collect(); - stray.sort(); - return Err(AttributeError::Parse(format!( - "{}: attribute files may only contain a top-level `data:` mapping; \ - found stray key(s): {}", - label, - stray.join(", ") - ))); - } - match data { - None | Some(Value::Null) => Ok(Map::new()), - Some(Value::Object(d)) => Ok(d), - Some(other) => Err(AttributeError::Parse(format!( - "{}: `data:` must be a mapping, got {}", - label, - type_name(&other) - ))), - } - }, - other => Err(AttributeError::Parse(format!( - "{}: top-level must be a `data:` mapping, got {}", - label, - type_name(&other) - ))), - } -} - -/// Recursively merge `incoming` into `acc`. Objects merge key-by-key; -/// two different scalars at the same path are a [`AttributeError::Conflict`]. -/// Identical values are a no-op (harmless overlap). `prefix` is the dotted -/// path for error context (`data.tenants.acme-eu.data_region`). -fn merge_object( - acc: &mut Map, - incoming: Map, - prefix: &str, -) -> Result<(), AttributeError> { - for (k, v) in incoming { - let path = format!("{}.{}", prefix, k); - if let Some(existing) = acc.get_mut(&k) { - match (existing, v) { - (Value::Object(e), Value::Object(iv)) => merge_object(e, iv, &path)?, - (existing_val, incoming_val) => { - if *existing_val != incoming_val { - return Err(AttributeError::Conflict { - path, - existing: existing_val.to_string(), - incoming: incoming_val.to_string(), - }); - } - }, - } - } else { - acc.insert(k, v); - } - } - Ok(()) -} - -fn type_name(v: &Value) -> &'static str { - match v { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "mapping", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn doc(label: &str, v: Value) -> (String, Value) { - (label.to_string(), v) - } - - #[test] - fn disjoint_subtrees_combine() { - let tree = merge_attribute_docs([ - doc( - "org.yaml", - json!({ "data": { "org": { "default_region": "us" } } }), - ), - doc( - "tenants.yaml", - json!({ "data": { "tenants": { "acme-eu": { "data_region": "eu" } } } }), - ), - ]) - .unwrap(); - let v = tree.as_value(); - assert_eq!(v.pointer("/org/default_region"), Some(&json!("us"))); - assert_eq!( - v.pointer("/tenants/acme-eu/data_region"), - Some(&json!("eu")) - ); - } - - #[test] - fn nested_objects_deep_merge() { - // Two files contribute different keys under the same subtree. - let tree = merge_attribute_docs([ - doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), - doc("b.yaml", json!({ "data": { "org": { "tier": "gold" } } })), - ]) - .unwrap(); - let v = tree.as_value(); - assert_eq!(v.pointer("/org/region"), Some(&json!("us"))); - assert_eq!(v.pointer("/org/tier"), Some(&json!("gold"))); - } - - #[test] - fn identical_leaf_is_not_a_conflict() { - let tree = merge_attribute_docs([ - doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), - doc("b.yaml", json!({ "data": { "org": { "region": "us" } } })), - ]) - .unwrap(); - assert_eq!(tree.as_value().pointer("/org/region"), Some(&json!("us"))); - } - - #[test] - fn conflicting_leaf_fails_fast() { - let err = merge_attribute_docs([ - doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), - doc("b.yaml", json!({ "data": { "org": { "region": "eu" } } })), - ]) - .unwrap_err(); - match err { - AttributeError::Conflict { - path, - existing, - incoming, - } => { - assert_eq!(path, "data.org.region"); - assert_eq!(existing, "\"us\""); - assert_eq!(incoming, "\"eu\""); - }, - other => panic!("expected Conflict, got {:?}", other), - } - } - - #[test] - fn object_vs_scalar_at_same_path_conflicts() { - let err = merge_attribute_docs([ - doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), - doc("b.yaml", json!({ "data": { "org": "flat" } })), - ]) - .unwrap_err(); - assert!(matches!(err, AttributeError::Conflict { .. })); - } - - #[test] - fn stray_top_level_key_rejected() { - // Forgot the `data:` wrapper. - let err = merge_attribute_docs([doc("oops.yaml", json!({ "org": { "region": "us" } }))]) - .unwrap_err(); - match err { - AttributeError::Parse(msg) => { - assert!(msg.contains("stray key"), "got: {}", msg); - assert!(msg.contains("org"), "got: {}", msg); - }, - other => panic!("expected Parse, got {:?}", other), - } - } - - #[test] - fn empty_and_missing_data_are_ok() { - let tree = merge_attribute_docs([ - doc("empty.yaml", Value::Null), - doc("nodata.yaml", json!({ "data": null })), - doc( - "real.yaml", - json!({ "data": { "org": { "region": "us" } } }), - ), - ]) - .unwrap(); - assert_eq!(tree.as_value().pointer("/org/region"), Some(&json!("us"))); - } - - #[test] - fn data_not_a_mapping_rejected() { - let err = merge_attribute_docs([doc("bad.yaml", json!({ "data": "flat" }))]).unwrap_err(); - assert!(matches!(err, AttributeError::Parse(_))); - } - - #[test] - fn load_reads_and_merges_real_files() { - // Exercise the file-reading path against temp files. - let dir = std::env::temp_dir().join(format!("apl_attr_test_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let a = dir.join("org.yaml"); - let b = dir.join("tenants.yaml"); - std::fs::write(&a, "data:\n org:\n default_region: us\n").unwrap(); - std::fs::write( - &b, - "data:\n tenants:\n acme-eu:\n data_region: eu\n", - ) - .unwrap(); - - let src = FileAttributeSource::new([a.clone(), b.clone()]); - let tree = src.load().unwrap(); - assert_eq!( - tree.as_value().pointer("/org/default_region"), - Some(&json!("us")) - ); - assert_eq!( - tree.as_value().pointer("/tenants/acme-eu/data_region"), - Some(&json!("eu")) - ); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn missing_file_is_load_error() { - let src = FileAttributeSource::new([PathBuf::from("/no/such/attrs.yaml")]); - assert!(matches!(src.load().unwrap_err(), AttributeError::Load(_))); - } -} diff --git a/crates/apl-cpex/src/candidate_constraint.rs b/crates/apl-cpex/src/candidate_constraint.rs deleted file mode 100644 index 8ffa13f4..00000000 --- a/crates/apl-cpex/src/candidate_constraint.rs +++ /dev/null @@ -1,362 +0,0 @@ -// Location: ./crates/apl-cpex/src/candidate_constraint.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Fold — combine the `restrict` constraints a request emitted (apl-core -// authoring IR) into one typed `CandidateConstraintExtension` (cpex-core -// wire type) the host router reads off the returned `Extensions`. This is -// the bridge between the pure policy language and the framework's typed -// extension slot, the same role `apply_session_taints` plays for taints. - -use apl_core::constraint::{CandidateConstraint, OnEmpty as AplOnEmpty}; -use cpex_core::extensions::{CandidateConstraintExtension, OnEmpty}; - -use std::collections::{BTreeMap, BTreeSet}; - -/// Two `restrict` effects require the same `custom` label to equal two -/// different values — no backend can satisfy both. The route handler maps -/// this to a fail-closed deny (never silently drops a requirement). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConstraintConflict { - pub key: String, - pub existing: String, - pub incoming: String, -} - -impl std::fmt::Display for ConstraintConflict { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "conflicting `restrict` custom label `{}`: `{}` vs `{}` \ - (no backend can be both)", - self.key, self.existing, self.incoming - ) - } -} - -/// Fold a request's emitted constraints into one typed extension. Returns -/// `Ok(None)` when there is nothing to emit (no constraints, or they fold -/// to an unrestricting result). Order-independent — the input may arrive -/// in any order (constraints from parallel branches merge unsorted). -/// -/// Monotone semantics: allow-sets **intersect**, -/// `deny_models` **union**, `max_cost_tier` ceilings **collect** into -/// `max_cost_tiers` (CPEX can't order tier names — the host reduces to the -/// min), `custom` **union**, `on_empty` takes the **strictest** -/// (`Deny` beats `Fallback`). A `custom` key required to hold two -/// different values is an unsatisfiable contradiction → `Err`. -pub fn fold_candidate_constraints( - constraints: &[CandidateConstraint], -) -> Result, ConstraintConflict> { - if constraints.is_empty() { - return Ok(None); - } - - let mut allow_models: Option> = None; - let mut allow_regions: Option> = None; - let mut allow_sites: Option> = None; - let mut deny_models: BTreeSet = BTreeSet::new(); - let mut tiers: BTreeSet = BTreeSet::new(); - let mut custom: BTreeMap = BTreeMap::new(); - // Start at the least strict and tighten. A restrict with no explicit - // policy still defaults to `Deny` (the parser default), so the fold - // lands on `Deny` in the common case. - let mut on_empty = OnEmpty::Fallback; - - for c in constraints { - intersect_into(&mut allow_models, c.allow_models.as_deref()); - intersect_into(&mut allow_regions, c.allow_regions.as_deref()); - intersect_into(&mut allow_sites, c.allow_sites.as_deref()); - deny_models.extend(c.deny_models.iter().cloned()); - if let Some(tier) = &c.max_cost_tier { - tiers.insert(tier.clone()); - } - for (k, v) in &c.custom { - if let Some(existing) = custom.get(k) { - if existing != v { - return Err(ConstraintConflict { - key: k.clone(), - existing: existing.clone(), - incoming: v.clone(), - }); - } - } else { - custom.insert(k.clone(), v.clone()); - } - } - on_empty = strictest(on_empty, map_on_empty(c.on_empty)); - } - - let folded = CandidateConstraintExtension { - allow_models, - deny_models: deny_models.into_iter().collect(), - allow_regions, - allow_sites, - max_cost_tiers: tiers.into_iter().collect(), - custom, - on_empty, - }; - - if folded.is_empty() { - Ok(None) - } else { - Ok(Some(folded)) - } -} - -/// The stricter of two `on_empty` policies — `Deny` beats `Fallback`. -fn strictest(a: OnEmpty, b: OnEmpty) -> OnEmpty { - match (a, b) { - (OnEmpty::Fallback, OnEmpty::Fallback) => OnEmpty::Fallback, - _ => OnEmpty::Deny, - } -} - -/// Map the apl-core authoring enum to the cpex-core wire enum. Kept -/// explicit (rather than a `From`) because the two live in crates that -/// don't depend on each other. -fn map_on_empty(v: AplOnEmpty) -> OnEmpty { - match v { - AplOnEmpty::Deny => OnEmpty::Deny, - AplOnEmpty::Fallback => OnEmpty::Fallback, - } -} - -/// Intersect `incoming` (a candidate's allow-set, or `None` for "no -/// constraint") into `acc`. `None` is the universe: intersecting with it -/// is a no-op, and the first `Some` seeded into a `None` accumulator -/// becomes the running set. Result is sorted + de-duplicated for a -/// deterministic blob. An empty result (`Some([])`) is retained — it means -/// "no candidate qualifies", which the host resolves via `on_empty`. -fn intersect_into(acc: &mut Option>, incoming: Option<&[String]>) { - let Some(incoming) = incoming else { - return; // no constraint from this restrict — universe, no-op - }; - let incoming: BTreeSet<&String> = incoming.iter().collect(); - match acc { - None => *acc = Some(incoming.into_iter().cloned().collect()), - Some(existing) => existing.retain(|e| incoming.contains(e)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn c() -> CandidateConstraint { - CandidateConstraint::default() - } - fn strs(items: &[&str]) -> Vec { - items.iter().map(|s| s.to_string()).collect() - } - fn fold(cs: &[CandidateConstraint]) -> CandidateConstraintExtension { - fold_candidate_constraints(cs).unwrap().unwrap() - } - - #[test] - fn empty_input_is_none() { - assert_eq!(fold_candidate_constraints(&[]).unwrap(), None); - } - - #[test] - fn single_passes_through_with_default_deny() { - let folded = fold(&[CandidateConstraint { - allow_regions: Some(strs(&["eu"])), - ..c() - }]); - assert_eq!(folded.allow_regions, Some(strs(&["eu"]))); - assert_eq!(folded.on_empty, OnEmpty::Deny); - } - - #[test] - fn allow_sets_intersect() { - let folded = fold(&[ - CandidateConstraint { - allow_models: Some(strs(&["vllm/*", "anthropic/*", "openai/*"])), - ..c() - }, - CandidateConstraint { - allow_models: Some(strs(&["anthropic/*", "openai/*", "cohere/*"])), - ..c() - }, - ]); - assert_eq!( - folded.allow_models, - Some(strs(&["anthropic/*", "openai/*"])) - ); - } - - #[test] - fn unconstrained_allow_is_noop() { - let folded = fold(&[ - CandidateConstraint { - allow_regions: Some(strs(&["eu"])), - ..c() - }, - CandidateConstraint { - deny_models: strs(&["openai/*"]), - ..c() - }, - ]); - assert_eq!(folded.allow_regions, Some(strs(&["eu"]))); - assert_eq!(folded.deny_models, strs(&["openai/*"])); - } - - #[test] - fn empty_intersection_retained_for_on_empty() { - let folded = fold(&[ - CandidateConstraint { - allow_regions: Some(strs(&["eu"])), - ..c() - }, - CandidateConstraint { - allow_regions: Some(strs(&["us"])), - ..c() - }, - ]); - assert_eq!(folded.allow_regions, Some(vec![])); - assert!(!folded.is_empty()); - } - - #[test] - fn deny_sets_union() { - let folded = fold(&[ - CandidateConstraint { - deny_models: strs(&["openai/*"]), - ..c() - }, - CandidateConstraint { - deny_models: strs(&["cohere/*", "openai/*"]), - ..c() - }, - ]); - assert_eq!(folded.deny_models, strs(&["cohere/*", "openai/*"])); - } - - #[test] - fn cost_tiers_collect_all_distinct() { - // Option-1 fold: CPEX can't order tiers, so emit every ceiling. - let folded = fold(&[ - CandidateConstraint { - max_cost_tier: Some("cheap".into()), - ..c() - }, - CandidateConstraint { - max_cost_tier: Some("standard".into()), - ..c() - }, - CandidateConstraint { - max_cost_tier: Some("cheap".into()), - ..c() - }, - ]); - assert_eq!(folded.max_cost_tiers, strs(&["cheap", "standard"])); - } - - #[test] - fn custom_union() { - let folded = fold(&[ - CandidateConstraint { - custom: [("gpu".to_string(), "h100".to_string())].into(), - ..c() - }, - CandidateConstraint { - custom: [("tenancy".to_string(), "dedicated".to_string())].into(), - ..c() - }, - ]); - assert_eq!(folded.custom.get("gpu"), Some(&"h100".to_string())); - assert_eq!(folded.custom.get("tenancy"), Some(&"dedicated".to_string())); - } - - #[test] - fn custom_same_key_same_value_ok() { - let folded = fold(&[ - CandidateConstraint { - custom: [("gpu".to_string(), "h100".to_string())].into(), - ..c() - }, - CandidateConstraint { - custom: [("gpu".to_string(), "h100".to_string())].into(), - ..c() - }, - ]); - assert_eq!(folded.custom.get("gpu"), Some(&"h100".to_string())); - } - - #[test] - fn custom_conflict_fails_closed() { - let err = fold_candidate_constraints(&[ - CandidateConstraint { - custom: [("gpu".to_string(), "h100".to_string())].into(), - ..c() - }, - CandidateConstraint { - custom: [("gpu".to_string(), "a100".to_string())].into(), - ..c() - }, - ]) - .unwrap_err(); - assert_eq!( - err, - ConstraintConflict { - key: "gpu".into(), - existing: "h100".into(), - incoming: "a100".into(), - } - ); - } - - #[test] - fn on_empty_strictest_wins() { - let folded = fold(&[ - CandidateConstraint { - allow_regions: Some(strs(&["eu"])), - on_empty: AplOnEmpty::Fallback, - ..c() - }, - CandidateConstraint { - deny_models: strs(&["openai/*"]), - on_empty: AplOnEmpty::Deny, - ..c() - }, - ]); - assert_eq!(folded.on_empty, OnEmpty::Deny); - } - - #[test] - fn all_fallback_stays_fallback() { - let folded = fold(&[ - CandidateConstraint { - allow_regions: Some(strs(&["eu"])), - on_empty: AplOnEmpty::Fallback, - ..c() - }, - CandidateConstraint { - deny_models: strs(&["openai/*"]), - on_empty: AplOnEmpty::Fallback, - ..c() - }, - ]); - assert_eq!(folded.on_empty, OnEmpty::Fallback); - } - - #[test] - fn fold_is_order_independent() { - let a = CandidateConstraint { - allow_models: Some(strs(&["vllm/*", "anthropic/*"])), - deny_models: strs(&["openai/*"]), - ..c() - }; - let b = CandidateConstraint { - allow_models: Some(strs(&["anthropic/*", "cohere/*"])), - max_cost_tier: Some("cheap".into()), - ..c() - }; - assert_eq!( - fold_candidate_constraints(&[a.clone(), b.clone()]).unwrap(), - fold_candidate_constraints(&[b, a]).unwrap() - ); - } -} diff --git a/crates/apl-cpex/src/cmf_invoker.rs b/crates/apl-cpex/src/cmf_invoker.rs deleted file mode 100644 index b936eb3b..00000000 --- a/crates/apl-cpex/src/cmf_invoker.rs +++ /dev/null @@ -1,531 +0,0 @@ -// Location: ./crates/apl-cpex/src/cmf_invoker.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CmfPluginInvoker` — `apl-core::PluginInvoker` impl bound to the CMF -// hook family. Drives dispatch off a pre-resolved [`RouteDispatchPlan`] -// (from [`DispatchCache`]) and forwards entries to -// `PluginManager::invoke_entries::(...)`, which runs the full -// executor pipeline (sequential / transform / audit / concurrent / -// fire-and-forget; on_error / timeouts / mode / write tokens all -// honored). Compile-time payload type safety is provided by the -// `CmfHook: HookTypeDef` bound on `invoke_entries`. -// -// # Request-scoped vs session-scoped state -// -// The invoker carries **request-scoped** state — payload + extensions -// — under interior mutability (`Arc>`) so mutations -// from one plugin call accumulate for the next call in the same -// request. **Session-scoped** state (labels that survive across requests -// in the same session) goes through the pluggable [`SessionStore`] -// trait: hydrated at `for_request` start, persisted via -// [`persist_session`] after route evaluation. Session ID is pulled from -// `extensions.agent.session_id`; absent → both ops are no-ops. -// -// Alongside the payload, the invoker records *whether* a plugin ever -// handed back a payload ([`payload_was_modified`]). That flag is the -// authoritative answer for the host: a plugin mutation is only -// detectable at the moment it's accepted, not by comparing message -// content afterwards (content comparison can't see mutations to -// non-text parts, and equality isn't defined on the CMF payload types). -// -// # Per-call taint extraction -// -// Each plugin invocation diffs `result.modified_extensions.security.labels` -// against the labels visible to *that call*. New labels become -// `PluginOutcome.taints` as `TaintEvent { scopes: vec![Session] }` — -// CMF's monotonic label channel is session-semantic by design, so -// Session is the natural default. Multi-scope plugin emissions (or -// `Message` scope) require either a future second label channel in -// Extensions or explicit config-side `Step::Taint { scopes: [...] }` / -// `Stage::Taint`. -// -// # Lifetime model -// -// One invoker instance per request. Host pre-builds the -// `MessagePayload`, hydrates session-scoped state via `for_request` -// (which is async because it awaits `SessionStore::load_labels`), then -// drives `evaluate_route`. After evaluation, host calls -// [`current_payload`] for body re-serialization and -// [`persist_session`] to commit accumulated session state. -// -// Background tasks returned by `invoke_entries` are dropped for v0; -// when audit/fire-and-forget plugin support is wired into APL's -// lifecycle, we'll thread a `BackgroundTasks` aggregator through the -// invoker. - -use std::collections::HashSet; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use async_trait::async_trait; -use tokio::sync::Mutex; - -use cpex_core::cmf::{CmfHook, MessagePayload}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::HookPhase; -use cpex_core::manager::PluginManager; - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::pipeline::{TaintEvent, TaintScope}; -use apl_core::step::{DispatchPhase, PluginError, PluginInvocation, PluginInvoker, PluginOutcome}; - -use crate::dispatch_plan::RouteDispatchPlan; -use crate::session_store::{SessionStore, SessionStoreError}; - -/// Bridges APL plugin dispatch to CMF-family CPEX hooks. -/// -/// Carries the request's `MessagePayload` and `Extensions` for its -/// entire lifetime so plugin mutations accumulate (one plugin's -/// `[REDACTED]` output is visible to the next plugin in the same -/// route; one plugin's added label seeds the next plugin's filter view). -pub struct CmfPluginInvoker { - manager: Arc, - /// Per-request extensions under interior mutability. Locked across - /// awaits — `tokio::sync::Mutex` is required because the executor's - /// `invoke_entries` is async. - extensions: Arc>, - /// Per-request payload under interior mutability. Same reasoning as - /// `extensions` — accumulated text rewrites have to be visible to - /// the next dispatch in the same request. - payload: Arc>, - /// Set the moment a plugin's `modified_payload` is accepted into - /// `payload` above. Request-scoped and sticky: once any plugin in - /// the request mutates, it stays `true`. - /// - /// This is the *signal* the host reads to decide whether to forward - /// a modified payload. It exists because the fact is only knowable - /// here — a caller comparing message content afterwards sees text - /// parts only, so a redaction of a `ToolResult` (or any other - /// non-text part) looks identical to no mutation at all. - payload_modified: AtomicBool, - /// Pre-resolved per-route plugin lineup. Built (or fetched from a - /// shared `DispatchCache`) at request start by the host. - plan: Arc, - /// Session ID resolved at request start by the 4-tier - /// [`session_resolver::resolve_session`] (token claim → header → - /// identity-derived → none). `None` for fully-anonymous traffic - /// (no claim, no header, no subject id) — hydration + persistence - /// become no-ops in that case. - session_id: Option, - /// Pluggable session-scoped state backend. `Arc` - /// rather than a generic so a single invoker type works for memory / - /// Redis / future-distributed stores without monomorphization churn. - session_store: Arc, - /// Labels present in `extensions.security.labels` immediately after - /// `SessionStore` hydration but before any plugins have run. Used - /// by `persist_session` to diff against final labels and append only - /// the additions to the session store. Empty when there was no - /// session_id (so no hydration happened). - initial_labels: HashSet, -} - -impl CmfPluginInvoker { - /// Construct an invoker bound to one request's payload + extensions - /// and the pre-resolved dispatch plan for the request's route. - /// Hydrates accumulated session-scoped labels into - /// `extensions.security.labels` before returning, so the first - /// plugin sees the full session-monotonic view. - pub async fn for_request( - manager: Arc, - mut extensions: Extensions, - payload: MessagePayload, - plan: Arc, - session_store: Arc, - ) -> Result { - // Resolve session id via the 4-tier resolver (token claim → - // header → identity-derived → none). Snapshotted before - // hydration so the lookup is independent of the COW write - // that hydration performs. - let session_id: Option = - crate::session_resolver::resolve_session(&extensions).map(|(sid, _src)| sid); - - // Hydration: union the session's accumulated labels into the - // request's security labels. Skipped when there's no session_id - // (anonymous/sessionless traffic has no state to load and is - // unaffected by a store outage). A load error propagates so the - // caller fails the request closed *before* any decision is made - // — a distributed store being unreachable must never silently - // present as "no accumulated labels". - if let Some(sid) = &session_id { - let stored = session_store.load_labels(sid).await?; - if !stored.is_empty() { - extensions = hydrate_labels(extensions, &stored); - } - } - - let initial_labels = snapshot_labels(&extensions); - - Ok(Self { - manager, - extensions: Arc::new(Mutex::new(extensions)), - payload: Arc::new(Mutex::new(payload)), - payload_modified: AtomicBool::new(false), - plan, - session_id, - session_store, - initial_labels, - }) - } - - /// Snapshot the current payload. Call after route evaluation to - /// extract the final (possibly-mutated) `MessagePayload` for body - /// re-serialization. - pub async fn current_payload(&self) -> MessagePayload { - self.payload.lock().await.clone() - } - - /// Did any plugin in this request hand back a payload? - /// - /// `true` from the moment a `modified_payload` is accepted into the - /// request's payload, and never resets. The host uses this to decide - /// whether to forward [`current_payload`] downstream. Reported - /// independently of *what* changed: a plugin that rewrites a tool - /// result, a tool call's arguments, or a thinking block is as - /// visible here as one that rewrites text. - /// - /// Deliberately `false` when a plugin returned a payload of the - /// wrong concrete type — that mutation was dropped (with a warning), - /// so claiming it landed would forward an unmutated payload while - /// asserting it changed. - pub fn payload_was_modified(&self) -> bool { - // Pairs with the `Release` store in `invoke`: plugin branches - // can run on other tasks (`dispatch_parallel`), so the write - // has to be visible to this read. - self.payload_modified.load(Ordering::Acquire) - } - - /// Snapshot the current extensions. Useful for hosts that need to - /// inspect the post-evaluation extension state (audit, telemetry). - pub async fn current_extensions(&self) -> Extensions { - self.extensions.lock().await.clone() - } - - /// Shared `Arc>` handle. Used by collaborators - /// (notably `DelegationPluginInvoker`) that need to mutate the - /// same request-scoped extensions this invoker sees — e.g. a - /// `delegate(...)` step minting a token needs to write - /// `raw_credentials.delegated_tokens.*` into the same Extensions - /// the next CMF plugin will read. - pub fn extensions_arc(&self) -> Arc> { - Arc::clone(&self.extensions) - } - - /// Shared `Arc` handle. Collaborators (e.g. - /// `DelegationPluginInvoker`) need this to look up their own - /// entries in the same per-route plan the CMF invoker uses. - pub fn plan_arc(&self) -> Arc { - Arc::clone(&self.plan) - } - - /// Drain APL-emitted session-scoped taints into the request's - /// `security.labels` so the existing label-monotonic flow - /// ([`persist_session`] below) picks them up. Filters by - /// `TaintScope::Session` — Message-scoped taints (and any future - /// scope) are deliberately ignored here; they have their own - /// destination (TBD: a labels slot on `MessagePayload`). - /// - /// Host (`AplRouteHandler`) calls this once per request after - /// `evaluate_pre` / `evaluate_post` returns, with the - /// `RouteDecision.taints` slice. No-op when the slice has no - /// Session-scoped entries — common for routes that don't taint. - pub async fn apply_session_taints(&self, taints: &[apl_core::pipeline::TaintEvent]) { - use apl_core::pipeline::TaintScope; - use cpex_core::extensions::SecurityExtension; - - let session_labels: Vec<&str> = taints - .iter() - .filter(|t| t.scopes.contains(&TaintScope::Session)) - .map(|t| t.label.as_str()) - .collect(); - if session_labels.is_empty() { - return; - } - let mut current = self.extensions.lock().await; - // `Extensions.security` is `Option>`. - // Initialize the slot if absent; `Arc::make_mut` gives us a - // mutable reference to the underlying value, cloning when - // other Arc holders exist (e.g., a downstream snapshot reader). - let arc = current - .security - .get_or_insert_with(|| Arc::new(SecurityExtension::default())); - let sec = Arc::make_mut(arc); - for label in session_labels { - sec.add_label(label); - } - } - - /// Persist session-scoped state added during this request. Diffs - /// current `security.labels` against the post-hydration snapshot - /// and appends new labels to the session store. No-op (returns - /// `Ok`) when there was no session ID or no new labels. Host calls - /// this exactly once after route evaluation completes. - /// - /// An append error is returned so the caller can fail the request - /// closed. Because this runs after the policy decision is - /// computed, the route handler converts an append error into a Deny - /// outcome rather than dropping the accumulated taint silently. - pub async fn persist_session(&self) -> Result<(), SessionStoreError> { - let Some(sid) = &self.session_id else { - return Ok(()); - }; - let current = self.extensions.lock().await; - let Some(security) = current.security.as_ref() else { - return Ok(()); - }; - let new_labels: Vec = security - .labels - .iter() - .filter(|l| !self.initial_labels.contains(l.as_str())) - .cloned() - .collect(); - drop(current); // release the lock before the await - if !new_labels.is_empty() { - self.session_store.append_labels(sid, &new_labels).await?; - } - Ok(()) - } -} - -#[async_trait] -impl PluginInvoker for CmfPluginInvoker { - async fn invoke( - &self, - plugin_name: &str, - _bag: &AttributeBag, - invocation: PluginInvocation<'_>, - ) -> Result { - let resolved = self - .plan - .get(plugin_name) - .ok_or_else(|| PluginError::NotFound(plugin_name.to_string()))?; - - // Snapshot extensions to read entity_type — the dispatcher - // needs it for hook routing. Dropped immediately so we don't - // hold the lock across the per-entry payload clone. - let request_entity_type: Option = { - let ext = self.extensions.lock().await; - ext.meta.as_ref().and_then(|m| m.entity_type.clone()) - }; - - // Pick the entry whose registered hook matches the current - // dispatch context via cpex-core's hook metadata table. - // Replaces the prior naming heuristic. - let dispatch_phase = match invocation.phase() { - DispatchPhase::Pre => HookPhase::Pre, - DispatchPhase::Post => HookPhase::Post, - }; - let entry = resolved - .pick_entry(request_entity_type.as_deref(), dispatch_phase) - .ok_or_else(|| { - PluginError::Dispatch(format!( - "plugin '{plugin_name}' has no hook matching dispatch \ - context (entity_type={:?}, phase={:?}); declared hooks: {:?}", - request_entity_type, - dispatch_phase, - resolved.entries_by_hook.keys().collect::>(), - )) - })?; - - // Snapshot the current payload + extensions — `invoke_entries` - // consumes by-value, so we clone for the call and keep the - // canonical copies in shared state for the next dispatch. - let current_payload = self.payload.lock().await.clone(); - let current_extensions = self.extensions.lock().await.clone(); - - // Per-call taint diff baseline. New labels in `result` minus - // these become `PluginOutcome.taints`. - let before_labels = snapshot_labels(¤t_extensions); - - // Per-call field baseline for pipeline-stage dispatch: the field - // as *this payload* holds it right now. - // - // This is the only sound thing to compare a readback against. The - // pipeline's own `value` may already carry earlier stages' edits - // (`mask`, `redact`, `hash`) that were never pushed into the - // payload, so comparing against it would read the payload's - // untouched original as "the plugin's new value" and hand the - // pre-redaction plaintext back to the pipeline, undoing the - // earlier stage. - let field_before = match invocation { - PluginInvocation::Field { name, phase, .. } => { - field_value_from_message(¤t_payload.message, name, phase) - }, - PluginInvocation::Step { .. } => None, - }; - - let (result, _bg) = self - .manager - .invoke_entries::( - std::slice::from_ref(entry), - current_payload, - current_extensions, - None, - ) - .await; - - // Map deny: violation reason → APL deny reason; plugin code → - // rule_source for audit attribution. - let decision = if result.is_denied() { - let (reason, rule_source) = match result.violation { - Some(v) => (Some(v.reason), v.code), - None => (None, "policy.forbidden".to_string()), - }; - Decision::Deny { - reason, - rule_source, - } - } else { - Decision::Allow - }; - - // Persist any plugin-side payload mutation back into the shared - // request payload. `PluginPayload` only exposes `as_any`, so we - // downcast-ref and clone. `MessagePayload: Clone` makes this - // cheap relative to the FFI/invoke cost. - // - // Gated on `payload_modified`, not on `modified_payload.is_some()`: - // the executor returns the final payload on every allowed - // pipeline, so `is_some()` is true even when the plugin never - // touched it. - let modified_value = if !result.payload_modified { - None - } else if let Some(mp_boxed) = result.modified_payload.as_ref() { - match mp_boxed.as_any().downcast_ref::() { - Some(modified) => { - *self.payload.lock().await = modified.clone(); - // Record the mutation for the host. `Release` so the - // flag is visible to `payload_was_modified` even when - // this call ran on a `dispatch_parallel` branch task. - self.payload_modified.store(true, Ordering::Release); - match invocation { - PluginInvocation::Field { name, phase, .. } => { - let rewritten = - field_value_from_message(&modified.message, name, phase) - .filter(|new_value| field_before.as_ref() != Some(new_value)); - if rewritten.is_none() { - tracing::debug!( - plugin = %plugin_name, - field = %name, - "plugin mutated the payload but not this field; \ - leaving the field value alone" - ); - } - rewritten - }, - PluginInvocation::Step { .. } => None, - } - }, - None => { - // Left out of `payload_modified` on purpose: nothing - // was written, so the host must keep forwarding the - // payload it already has. - tracing::warn!( - plugin = %plugin_name, - "CmfPluginInvoker: modified_payload was not MessagePayload \ - (downcast failed) — dropping the mutation" - ); - None - }, - } - } else { - None - }; - - // Promote modified extensions back into shared state + extract - // newly-added labels as taints. The executor returns - // `Option` for the modified view — `Some` only when - // a plugin actually changed extensions. The executor has - // already validated label monotonicity on the way out. - let taints = if let Some(modified_ext) = result.modified_extensions { - let after_labels = snapshot_labels(&modified_ext); - let new_labels: Vec = - after_labels.difference(&before_labels).cloned().collect(); - *self.extensions.lock().await = modified_ext; - new_labels - .into_iter() - .map(|label| TaintEvent { - label, - // v0: CMF's `security.labels` is session-semantic by - // design (monotonic accumulation). Plugins that need - // Message-scoped taints emit them via config-side - // `Step::Taint`/`Stage::Taint` for now. - scopes: vec![TaintScope::Session], - }) - .collect() - } else { - Vec::new() - }; - - Ok(PluginOutcome { - decision, - taints, - modified_value, - }) - } -} - -/// Read the value of one pipeline field out of a message. -/// -/// A plugin dispatched from an `args:` / `result:` stage is handed the -/// whole message, not the field, so its new value for that field has to -/// be read back out. The projection matches what APL evaluated against: -/// Pre addresses args, Post addresses result. `field` is relative to -/// that root. -/// -/// Two shapes: -/// * object projection (a tool call's arguments, a structured tool -/// result) → look up `field` in it, `None` when absent. -/// * scalar projection (a text-only message, whose whole content is -/// the field) → the projection itself. -/// -/// The caller compares the result against the value the pipeline is -/// holding: equal, or `None` here, both mean "this plugin didn't change -/// this field". The plugin's payload mutation is recorded separately, so -/// reporting no field change never drops it. -fn field_value_from_message( - message: &cpex_core::cmf::Message, - field: &str, - phase: DispatchPhase, -) -> Option { - let projection = match phase { - DispatchPhase::Pre => crate::message_projection::extract_args_from_message(message), - DispatchPhase::Post => crate::message_projection::extract_result_from_message(message), - }; - if projection.is_object() { - apl_core::get_dotted(&projection, field).cloned() - } else { - Some(projection) - } -} - -/// Snapshot `extensions.security.labels` as an owned `HashSet`. -/// Empty when security is absent. -fn snapshot_labels(extensions: &Extensions) -> HashSet { - extensions - .security - .as_ref() - .map(|s| s.labels.iter().cloned().collect()) - .unwrap_or_default() -} - -/// Add `labels` to `extensions.security.labels` (monotonic union). -/// Creates a security extension if absent. Used at hydration time — -/// merges the SessionStore's accumulated labels into the request view -/// so the first plugin sees the full picture. -fn hydrate_labels(mut extensions: Extensions, labels: &[String]) -> Extensions { - // Clone the Arc'd security into an owned struct so we can mutate. - // Most slots stay refcount-shared; only security is materialized. - let mut security = extensions - .security - .as_ref() - .map(|s| (**s).clone()) - .unwrap_or_default(); - for l in labels { - security.add_label(l.clone()); - } - extensions.security = Some(Arc::new(security)); - extensions -} diff --git a/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs deleted file mode 100644 index b3edce7e..00000000 --- a/crates/apl-cpex/src/delegation_invoker.rs +++ /dev/null @@ -1,506 +0,0 @@ -// Location: ./crates/apl-cpex/src/delegation_invoker.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `DelegationPluginInvoker` — `apl-core::DelegationInvoker` impl -// bound to the `TokenDelegateHook` family. Drives dispatch off a -// pre-resolved [`RouteDispatchPlan::delegation_entries`] and forwards -// to `PluginManager::invoke_entries::(...)`. -// -// # When this runs -// -// The apl-core evaluator calls -// `DelegationInvoker::delegate(&DelegateStep)` once per `Step::Delegate` -// it encounters in a `pre_invocation:` / `post_invocation:` block. The invoker: -// -// 1. Looks up the resolved `token.delegate` entry for the step's -// plugin name in the dispatch plan. -// 2. Constructs a `cpex_core::delegation::DelegationPayload` from -// the inbound bearer token (from -// `Extensions.raw_credentials.inbound_tokens[User]`) plus the -// step's `config_override` (target / audience / permissions / -// attenuation — schema is plugin-defined; we map a few -// well-known keys onto the typed payload builders and stash -// everything else as metadata for plugin-specific consumption). -// 3. Calls `mgr.invoke_entries::(&[entry], ...)`. -// 4. Pulls the resulting `DelegationPayload` from the -// `PipelineResult`, applies it to the shared `Extensions` (via -// `apply_to_extensions`), and returns a `DelegationOutcome` with -// the granted_* fields extracted from the minted token. -// -// # Shared extensions -// -// This invoker shares the same `Arc>` as -// `CmfPluginInvoker` for the same request. That means when -// `delegate(...)` writes `raw_credentials.delegated_tokens.*`, the -// next CMF plugin in the chain (or downstream evaluator phases) sees -// it. Get the shared handle via `CmfPluginInvoker::extensions_arc()`. - -use std::sync::Arc; - -use async_trait::async_trait; -use chrono::SecondsFormat; -use tokio::sync::Mutex; - -use cpex_core::delegation::{ - payload::{AuthEnforcedBy, TargetType}, - DelegationPayload, DelegationSubject, TokenDelegateHook, -}; -use cpex_core::extensions::raw_credentials::TokenRole; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; - -use apl_core::evaluator::Decision; -use apl_core::step::{DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome}; - -use crate::dispatch_plan::RouteDispatchPlan; - -/// Bridges APL `delegate(...)` step dispatch to CPEX -/// `TokenDelegateHook` plugins. -/// -/// Carries the request's shared `Extensions` so mutations from a -/// `delegate(...)` step (minted token, updated delegation chain) -/// land in the same `Extensions` the CMF invoker is reading. -pub struct DelegationPluginInvoker { - manager: Arc, - /// Same `Arc>` as the CMF invoker for this - /// request — sharing this handle is what makes minted tokens - /// visible to downstream CMF plugins. - extensions: Arc>, - /// Pre-resolved per-route delegation lineup. Built at request - /// start by the host (or fetched from a shared `DispatchCache`). - plan: Arc, -} - -impl DelegationPluginInvoker { - /// Construct an invoker bound to the request's shared extensions - /// and the route's pre-resolved dispatch plan. Take the - /// extensions Arc from `CmfPluginInvoker::extensions_arc()` so - /// the two invokers see the same mutable Extensions. - pub fn new( - manager: Arc, - extensions: Arc>, - plan: Arc, - ) -> Self { - Self { - manager, - extensions, - plan, - } - } -} - -#[async_trait] -impl DelegationInvoker for DelegationPluginInvoker { - async fn delegate(&self, step: &DelegateStep) -> Result { - // Resolve the plugin's token.delegate entry out of - // `self.plan.delegation_entries`. Routes that don't reference this - // plugin in `pre_invocation:` / `post_invocation:` at compile time - // won't have an entry there — surface that as NotFound so the - // evaluator's on_error semantics kick in. - let entry = self - .plan - .delegation_entries - .get(&step.plugin_name) - .ok_or_else(|| DelegationError::NotFound(step.plugin_name.clone()))? - .clone(); - - // Snapshot extensions to construct the payload and pass into - // invoke_entries. The canonical copy stays under the Mutex; this - // snapshot is the per-call working copy. - let current_extensions = self.extensions.lock().await.clone(); - - // Read step args first — the subject / actor role selection below - // reads from them. Step `config_override` is a yaml map per the IR; - // extract a few well-known keys onto the typed DelegationPayload - // builders. Unknown keys still flow through to the plugin via the - // per-call config-override pathway (plugins consume them from their - // `cfg.config`). Recognized keys: `target` (required), `subject`, - // `actor`, `audience`, `permissions`, `target_type`, - // `auth_enforced_by`; everything else stays opaque. - // - // There is deliberately no `mode` key: the delegation mode is - // *derived* from `subject` by the handler rather than declared, so a - // route can't claim on-behalf-of-user while handing over a workload - // SVID. - let cfg = step.config_override.as_ref().and_then(|v| v.as_mapping()); - - // Resolve who the exchange is *for*. Defaults to the user - // (on-behalf-of); `subject: caller_workload` selects the - // caller's SVID for the no-user, agent-acting-autonomously - // exchange, `subject: client` the OAuth client token, and - // `subject: this_workload` means *we* are the principal. - // - // this_workload is the one subject with no inbound credential to - // read — this instance proves who it is with its own - // credentials, not with anything the caller sent. So - // `inbound_role()` returns None and the bearer token stays - // empty *by design*; the handler must not treat that as the - // "missing credential" error it is for every other subject. - let subject = subject_from_cfg(cfg)?; - let bearer_token = subject - .inbound_role() - .and_then(|role| { - current_extensions - .raw_credentials - .as_ref() - .and_then(|rc| rc.inbound_tokens.get(&role)) - .map(|tok| (*tok.token).clone()) - }) - .unwrap_or_default(); - - let target_name: String = cfg - .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) - .and_then(|v| v.as_str()) - .unwrap_or(&step.plugin_name) - .to_string(); - - // Optional RFC 8693 actor. When the step opts in with e.g. - // `actor: client`, attach that inbound credential as the - // actor_token so the minted token records `act` = actor alongside - // `sub` = subject. This is the on-behalf-of shape: `subject: user` - // (or `client`) with the acting party recorded as `act`. An absent - // credential leaves the exchange single-token. Parse + validate it - // before `subject` is consumed by the payload below. - let actor_role = role_from_cfg(cfg, "actor")?; - reject_unsupported_actor_combo(&subject, actor_role.as_ref())?; - - // Carry the subject onto the payload. The delegator sees only - // opaque token bytes, so this is the only way it can tell an - // agent-acting-autonomously exchange from an on-behalf-of-user - // one — and that decides how the minted token gets attributed. - let mut payload = DelegationPayload::new(bearer_token, target_name).with_subject(subject); - - if let Some(actor_role) = actor_role { - let actor_token = current_extensions - .raw_credentials - .as_ref() - .and_then(|rc| rc.inbound_tokens.get(&actor_role)) - .map(|tok| (*tok.token).clone()) - .unwrap_or_default(); - if !actor_token.is_empty() { - payload = payload.with_actor(actor_role, actor_token); - } - } - - if let Some(audience) = cfg - .and_then(|m| m.get(serde_yaml::Value::String("audience".into()))) - .and_then(|v| v.as_str()) - { - payload = payload.with_target_audience(audience); - } - if let Some(perms) = cfg - .and_then(|m| m.get(serde_yaml::Value::String("permissions".into()))) - .and_then(|v| v.as_sequence()) - { - let list: Vec = perms - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); - if !list.is_empty() { - payload = payload.with_required_permissions(list); - } - } - if let Some(t_kind) = cfg - .and_then(|m| m.get(serde_yaml::Value::String("target_type".into()))) - .and_then(|v| v.as_str()) - { - payload = payload.with_target_type(target_type_from_str(t_kind)); - } - if let Some(enforcer) = cfg - .and_then(|m| m.get(serde_yaml::Value::String("auth_enforced_by".into()))) - .and_then(|v| v.as_str()) - { - payload = payload.with_auth_enforced_by(auth_enforced_by_from_str(enforcer)); - } - - // Dispatch. The plan's pre-resolved entry already has any - // per-route config override merged into the plugin's - // instance config; what we're passing on this call is the - // typed payload (target / audience / permissions / etc.). - let (result, _bg) = self - .manager - .invoke_entries::( - std::slice::from_ref(&entry), - payload, - current_extensions, - None, - ) - .await; - - // Translate the result. - if !result.continue_processing { - // Plugin denied (IdP refusal, validation failure, etc.). - let decision = match result.violation { - Some(v) => Decision::Deny { - reason: Some(v.reason), - rule_source: v.code, - }, - None => Decision::Deny { - reason: Some(format!( - "delegate `{}` denied without violation detail", - step.plugin_name - )), - rule_source: step.source.clone(), - }, - }; - return Ok(DelegationOutcome::deny(decision)); - } - - // Pull the resolved DelegationPayload and apply to shared - // extensions so downstream code sees the minted token / - // updated chain. - let resolved = DelegationPayload::from_pipeline_result(&result).ok_or_else(|| { - DelegationError::Dispatch(format!( - "plugin `{}` returned allow but no DelegationPayload", - step.plugin_name, - )) - })?; - - { - let mut ext_lock = self.extensions.lock().await; - let merged = resolved.clone().apply_to_extensions(ext_lock.clone()); - *ext_lock = merged; - } - - // Extract granted_* for the evaluator to surface into the bag. - let (granted_permissions, granted_audience, granted_expires_at) = - match resolved.delegated_token { - Some(tok) => ( - tok.scopes, - Some(tok.audience), - Some(tok.expires_at.to_rfc3339_opts(SecondsFormat::Secs, true)), - ), - None => (Vec::new(), None, None), - }; - - Ok(DelegationOutcome { - decision: Decision::Allow, - granted_permissions, - granted_audience, - granted_expires_at, - }) - } -} - -/// Resolve a `TokenRole` from the `actor` step-config key, whose -/// value names an *inbound* credential. Returns `None` when the key -/// is absent or unrecognized, so the actor is simply omitted and the -/// exchange stays single-token — never silently substituted for a -/// typo'd role. -/// -/// Unlike `subject`, an actor is always an inbound credential: the -/// actor is by definition a party that presented itself to us. That's -/// why this returns `TokenRole` while the subject resolves to a -/// [`DelegationSubject`], which additionally admits `this_workload`. -/// -/// `"workload"` is accepted as a legacy spelling of `caller_workload`. -/// Parse the `subject:` step key into a [`DelegationSubject`], on the -/// production path (`DelegationSubject::from_config_str`). Distinguishes: -/// - **absent** → the documented default (`user`); -/// - **present but not a string / unrecognized** → `Err` — a typo'd -/// subject must fail rather than silently exchange a different -/// credential shape. -fn subject_from_cfg( - cfg: Option<&serde_yaml::Mapping>, -) -> Result { - let Some(v) = cfg.and_then(|m| m.get(serde_yaml::Value::String("subject".into()))) else { - return Ok(DelegationSubject::default()); - }; - let s = v - .as_str() - .ok_or_else(|| DelegationError::InvalidConfig("`subject:` must be a string".into()))?; - DelegationSubject::from_config_str(s).ok_or_else(|| { - DelegationError::InvalidConfig(format!( - "unknown `subject: {s}` (expected user | client | caller_workload | this_workload)" - )) - }) -} - -/// Parse an actor-style role key (`actor:`) into a [`TokenRole`]. -/// - `Ok(None)` — key absent (documented default: no actor); -/// - `Ok(Some(role))` — present and recognized; -/// - `Err(..)` — present but invalid, which must fail rather than -/// silently drop the actor. -fn role_from_cfg( - cfg: Option<&serde_yaml::Mapping>, - key: &str, -) -> Result, DelegationError> { - let Some(v) = cfg.and_then(|m| m.get(serde_yaml::Value::String(key.into()))) else { - return Ok(None); - }; - let s = v - .as_str() - .ok_or_else(|| DelegationError::InvalidConfig(format!("`{key}:` must be a string")))?; - match s { - "user" => Ok(Some(TokenRole::User)), - "client" => Ok(Some(TokenRole::Client)), - "caller_workload" | "workload" => Ok(Some(TokenRole::CallerWorkload)), - other => Err(DelegationError::InvalidConfig(format!( - "unknown `{key}: {other}` (expected user | client | caller_workload)" - ))), - } -} - -/// Reject `actor:` combined with a subject the OAuth delegator can't attach -/// an actor to: `caller_workload` is itself the subject (via the leg-1 base -/// token — no separate actor slot), and `this_workload` uses a -/// `client_credentials` grant, where `actor_token` has no meaning (RFC 6749 -/// §4.4). The delegator omits the actor in both cases, so accepting the key -/// would silently mislead — fail instead. -fn reject_unsupported_actor_combo( - subject: &DelegationSubject, - actor: Option<&TokenRole>, -) -> Result<(), DelegationError> { - if actor.is_some() - && matches!( - subject, - DelegationSubject::CallerWorkload | DelegationSubject::ThisWorkload - ) - { - return Err(DelegationError::InvalidConfig( - "`actor:` is not supported with `subject: caller_workload` or \ - `subject: this_workload` — the actor would be silently ignored" - .into(), - )); - } - Ok(()) -} - -fn target_type_from_str(s: &str) -> TargetType { - match s.to_ascii_lowercase().as_str() { - "tool" => TargetType::Tool, - "agent" => TargetType::Agent, - "resource" => TargetType::Resource, - "service" => TargetType::Service, - other => TargetType::Custom(other.to_string()), - } -} - -fn auth_enforced_by_from_str(s: &str) -> AuthEnforcedBy { - match s.to_ascii_lowercase().as_str() { - "caller" => AuthEnforcedBy::Caller, - "target" => AuthEnforcedBy::Target, - // Unknown values default to Caller — matches DelegationPayload::new's default. - _ => AuthEnforcedBy::Caller, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Parse a YAML fragment into the step `config_override` mapping - /// shape the invoker receives (`step.config_override.as_mapping()`). - fn cfg(yaml: &str) -> serde_yaml::Mapping { - serde_yaml::from_str::(yaml) - .expect("valid yaml") - .as_mapping() - .expect("yaml is a mapping") - .clone() - } - - // --- subject selection (production path: subject_from_cfg -> DelegationSubject) --- - - #[test] - fn subject_variants_parse() { - let s = |y| subject_from_cfg(Some(&cfg(y))).unwrap(); - assert_eq!(s("subject: user"), DelegationSubject::User); - assert_eq!(s("subject: client"), DelegationSubject::Client); - assert_eq!( - s("subject: caller_workload"), - DelegationSubject::CallerWorkload - ); - assert_eq!(s("subject: workload"), DelegationSubject::CallerWorkload); // legacy alias - assert_eq!(s("subject: this_workload"), DelegationSubject::ThisWorkload); - } - - #[test] - fn subject_absent_defaults_to_user() { - // An absent `subject:` is the documented default (on-behalf-of user). - assert_eq!( - subject_from_cfg(Some(&cfg("target: hr-service"))).unwrap(), - DelegationSubject::User - ); - assert_eq!(subject_from_cfg(None).unwrap(), DelegationSubject::User); - } - - #[test] - fn subject_typo_is_rejected_not_defaulted_to_user() { - // A present-but-invalid subject must FAIL, not silently become user - // (which would exchange a different credential than the author asked). - let err = subject_from_cfg(Some(&cfg("subject: workloadd"))).unwrap_err(); - assert!(matches!(err, DelegationError::InvalidConfig(_)), "{err:?}"); - } - - #[test] - fn subject_non_string_is_rejected() { - let err = subject_from_cfg(Some(&cfg("subject: [a, b]"))).unwrap_err(); - assert!(matches!(err, DelegationError::InvalidConfig(_)), "{err:?}"); - } - - // --- actor selection (production path: role_from_cfg -> Result>) --- - - #[test] - fn actor_variants_parse() { - let a = |y| role_from_cfg(Some(&cfg(y)), "actor").unwrap(); - assert_eq!(a("actor: user"), Some(TokenRole::User)); - assert_eq!(a("actor: client"), Some(TokenRole::Client)); - assert_eq!(a("actor: workload"), Some(TokenRole::CallerWorkload)); - } - - #[test] - fn actor_absent_is_ok_none() { - // Absent actor → single-token exchange (no error). - assert_eq!( - role_from_cfg(Some(&cfg("subject: user")), "actor").unwrap(), - None - ); - assert_eq!(role_from_cfg(None, "actor").unwrap(), None); - } - - #[test] - fn actor_typo_is_rejected_not_silently_dropped() { - // A present-but-invalid actor must FAIL, not silently drop the actor. - let err = role_from_cfg(Some(&cfg("actor: workloadd")), "actor").unwrap_err(); - assert!(matches!(err, DelegationError::InvalidConfig(_)), "{err:?}"); - } - - // --- both keys coexist (user subject + workload actor) --- - - #[test] - fn subject_and_actor_resolve_independently() { - let m = cfg("subject: user\nactor: workload"); - assert_eq!(subject_from_cfg(Some(&m)).unwrap(), DelegationSubject::User); - assert_eq!( - role_from_cfg(Some(&m), "actor").unwrap(), - Some(TokenRole::CallerWorkload) - ); - } - - // --- actor is rejected with subjects that have no actor slot --- - - #[test] - fn actor_with_user_or_client_subject_is_allowed() { - for s in [DelegationSubject::User, DelegationSubject::Client] { - assert!(reject_unsupported_actor_combo(&s, Some(&TokenRole::Client)).is_ok()); - } - // No actor at all is always fine. - assert!(reject_unsupported_actor_combo(&DelegationSubject::CallerWorkload, None).is_ok()); - } - - #[test] - fn actor_with_workload_or_this_workload_subject_is_rejected() { - // These subjects have no actor slot — the delegator would silently - // drop the actor, so the combo must fail rather than mislead. - for s in [ - DelegationSubject::CallerWorkload, - DelegationSubject::ThisWorkload, - ] { - let err = - reject_unsupported_actor_combo(&s, Some(&TokenRole::CallerWorkload)).unwrap_err(); - assert!(matches!(err, DelegationError::InvalidConfig(_)), "{err:?}"); - } - } -} diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs deleted file mode 100644 index 9ecfdaea..00000000 --- a/crates/apl-cpex/src/dispatch_plan.rs +++ /dev/null @@ -1,533 +0,0 @@ -// Location: ./crates/apl-cpex/src/dispatch_plan.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `RouteDispatchPlan` + `DispatchCache` — pre-resolved per-route plugin -// lineup that lets APL bypass cpex-core's hook-name + condition routing -// while still going through the executor's full 5-phase pipeline. -// -// # Why pre-resolve? -// -// cpex-core's `invoke_named(hook_name, ...)` resolves the lineup on -// every call: hook lookup → route/condition filter → group by mode → -// dispatch. APL routes are already authoritative lineups (the YAML's -// `routes..policy: [plugin(x), plugin(y)]` IS the plan). Re-resolving -// per call wastes work and lets cpex-core's parallel routing model -// (entity-based conditions) override APL's intent. -// -// Building once per `(route_key, snapshot_generation)` and caching turns -// dispatch into: cache lookup → pick handler by invocation context → -// call `manager.invoke_entries::(&[entry], ...)`. -// -// # Override materialization -// -// When APL declares a route-level `plugins.:` block that narrows -// `capabilities` or changes `on_error`, the plan creates a derived -// `PluginRef` wrapping the same plugin `Arc` with a merged -// `TrustedConfig`. Each derived -// PluginRef gets a fresh `AtomicBool` circuit breaker — failures in the -// override-context plugin don't disable the base, and vice versa. -// -// # Hook-context classification (v0) -// -// A plugin may register handlers for multiple hooks (e.g. both -// `cmf.tool_pre_invoke` for policy steps and `cmf.field_redact` for -// args/result pipelines). The plan picks one handler per invocation -// context (Step vs Field) by a naming heuristic — hook names containing -// `field`, `redact`, `scan`, or `validate` are treated as field -// handlers. When the heuristic stops being sufficient, the plugin -// declaration will gain an explicit `{step: ..., field: ...}` mapping -// form alongside the flat hook list. - -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, RwLock}; - -use cpex_core::delegation::HOOK_TOKEN_DELEGATE; -use cpex_core::elicitation::HOOK_ELICIT; -use cpex_core::hooks::{lookup_hook_metadata, HookPhase}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::OnError; -use cpex_core::registry::HookEntry; - -use apl_core::pipeline::Stage; -use apl_core::plugin_decl::{EffectivePlugin, PluginRegistry}; -use apl_core::rules::{CompiledRoute, Effect}; - -/// Per-plugin pre-resolved entries for one route. Stores ALL hook -/// entries the plugin registered (keyed by hook name) so the -/// dispatcher can pick the right one for the current context via the -/// cpex-core hook routing table (`hooks::metadata::lookup`). -/// -/// Replaces the prior `step_entry` / `field_entry` slot model, which -/// used a brittle naming heuristic to classify hooks and silently -/// collapsed plugins with multiple step-context hooks (e.g. both -/// `tool_pre_invoke` and `tool_post_invoke`) to a single entry. -#[derive(Clone)] -pub struct RoutePluginEntry { - pub plugin_name: String, - /// All hook entries the plugin registered, keyed by hook name. - /// Per-call overrides (route-level config / caps / on_error) are - /// already applied via `build_override_entries` before being - /// stored here. - pub entries_by_hook: HashMap, -} - -impl RoutePluginEntry { - /// Pick the entry whose registered hook matches the current - /// dispatch context. Walks `entries_by_hook`, consults the - /// cpex-core hook metadata table for each, returns the first - /// matching entry. - /// - /// `requested_entity_type` comes from the request's - /// `MetaExtension.entity_type` (or `None` if the dispatcher - /// doesn't have one — in which case any hook's entity_type - /// matches). `requested_phase` comes from the APL invocation - /// context — `Pre` for `args:` / `pre_invocation:`, `Post` for - /// `result:` / `post_invocation:`, `Unphased` for unphased - /// dispatchers (rare in APL). - /// - /// Returns `None` when the plugin has no hook matching the - /// context — caller surfaces this as `PluginError::Dispatch` - /// with the requested context in the message. - pub fn pick_entry( - &self, - requested_entity_type: Option<&str>, - requested_phase: HookPhase, - ) -> Option<&HookEntry> { - self.entries_by_hook - .iter() - .find(|(hook_name, _)| { - lookup_hook_metadata(hook_name).matches(requested_entity_type, requested_phase) - }) - .map(|(_, entry)| entry) - } -} - -/// A route's resolved plugin lineup. One per `(route_key, generation)` -/// in the cache. -/// -/// `plugins` holds entries for CMF-family dispatch (policy steps, -/// pipe-chain stages). `delegation_entries` holds entries for the -/// `token.delegate` hook used by `Step::Delegate` — kept separate -/// because the hook family is different and the dispatch is -/// per-call rather than per-route-chain. -#[derive(Clone, Default)] -pub struct RouteDispatchPlan { - pub plugins: HashMap, - /// Plugin name → resolved `token.delegate` hook entry for routes - /// that declared `delegate(...)` steps. Empty when the route has - /// no delegation. Built at plan time to avoid per-request - /// `find_plugin_entries` lookups in the hot path. - pub delegation_entries: HashMap, - /// Plugin name → resolved `elicit` hook entry for routes that - /// declared `require_approval(...)` / `confirm(...)` / … steps. Same - /// `name → entry` shape as `delegation_entries` — elicitation routes - /// by plugin name, not by `(kind, channel)`. Empty when the route has - /// no elicitation. - pub elicitation_entries: HashMap, -} - -impl RouteDispatchPlan { - /// Build a plan for the given route. Walks all steps + pipeline - /// stages, collects the unique set of plugin names, resolves each - /// against cpex-core, and applies any APL route-level overrides. - /// - /// Plugins referenced by APL but absent from cpex-core's registry - /// (or absent from the APL `plugins:` block) are logged at `warn` - /// and excluded — dispatch then fails with `PluginError::NotFound` - /// when those plugins are invoked, which is the right behavior for - /// surfacing config drift. - pub async fn build( - route: &CompiledRoute, - registry: &PluginRegistry, - manager: &PluginManager, - ) -> Self { - let mut plan = Self::default(); - for name in collect_plugin_names(route) { - let eff = match EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { - Some(e) => e, - None => { - tracing::warn!( - plugin = %name, - route = %route.route_key, - "APL route references plugin not in `plugins:` block — skipping", - ); - continue; - }, - }; - - // Pull the three overrideable values off the effective view. - // `EffectivePlugin` borrows from the registry / route overrides, - // so the captures here are slice / Option<&Value> refs. - let override_block = route.plugin_overrides.get(&name); - let config_override = override_block.and_then(|o| o.config.as_ref()); - let caps_override: Option> = if matches!( - eff.capabilities, - apl_core::plugin_decl::CapsView::Override(_) - ) { - Some(eff.capabilities.as_slice().iter().cloned().collect()) - } else { - None - }; - let on_error_override = override_block - .and_then(|o| o.on_error.as_deref()) - .and_then(parse_on_error); - - // Hand the override decision to cpex-core. When no overrides - // are declared, this returns the base entries unchanged - // (no allocation, no factory call). When only caps/on_error - // differ, it wraps the shared base plugin in a fresh - // `PluginRef` with merged trusted config. When config - // differs, it invokes the factory + initializes a brand-new - // instance with its own circuit breaker. - let entries = manager - .build_override_entries( - &name, - config_override, - caps_override.as_ref(), - on_error_override, - ) - .await; - if entries.is_empty() { - tracing::warn!( - plugin = %name, - route = %route.route_key, - "APL plugin not resolvable (not registered, factory missing, \ - or override construction failed) — skipping", - ); - continue; - } - - // Store every (hook_name, HookEntry) pair the plugin - // registered. Dispatch-time entry selection (pick_entry) - // consults cpex-core's hook routing table per hook name. - // Replaces the prior naming heuristic. - let mut entries_by_hook: HashMap = HashMap::new(); - for (hook_name, entry) in entries { - entries_by_hook.insert(hook_name, entry); - } - - plan.plugins.insert( - name.clone(), - RoutePluginEntry { - plugin_name: name, - entries_by_hook, - }, - ); - } - - // Resolve token.delegate entries for any plugins the route - // calls via `Step::Delegate`. These don't go through the - // step/field classification — they're a separate hook family. - // We still apply per-call config overrides via the existing - // `build_override_entries` pathway, threading the step's - // `config_override` as the only override surface (per-step - // caps and on_error overrides aren't exposed on delegation - // entries — the on_error lives in the IR step itself and is - // honored by the evaluator). - for name in collect_delegate_plugin_names(route) { - let entries = manager - .build_override_entries(&name, None, None, None) - .await; - // Pick the first token.delegate entry. Per delegation-hooks - // spec, plugins typically register one handler under the - // single `token.delegate` hook name; multiple handlers - // would be unusual. - let delegate_entry = entries - .into_iter() - .find(|(hook_name, _)| hook_name == HOOK_TOKEN_DELEGATE); - if let Some((_, entry)) = delegate_entry { - plan.delegation_entries.insert(name, entry); - } else { - tracing::warn!( - plugin = %name, - route = %route.route_key, - "APL route references delegate plugin not registered under \ - token.delegate hook — `delegate(...)` step will fail at dispatch", - ); - } - } - - // Resolve `elicit` entries for any plugins the route calls via an - // elicitation verb (`require_approval(...)`, `confirm(...)`, …). - // Same `name → entry` resolution as delegation — elicitation - // routes by plugin name, not `(kind, channel)`. - for name in collect_elicit_plugin_names(route) { - let entries = manager - .build_override_entries(&name, None, None, None) - .await; - let elicit_entry = entries - .into_iter() - .find(|(hook_name, _)| hook_name == HOOK_ELICIT); - if let Some((_, entry)) = elicit_entry { - plan.elicitation_entries.insert(name, entry); - } else { - tracing::warn!( - plugin = %name, - route = %route.route_key, - "APL route references elicitation plugin not registered under \ - elicit hook — `require_approval(...)`/`confirm(...)` step will \ - fail at dispatch", - ); - } - } - - plan - } - - /// Look up the resolved entries for a plugin by name. None when the - /// plugin wasn't referenced by the route (or was skipped during - /// build due to config drift). - pub fn get(&self, plugin_name: &str) -> Option<&RoutePluginEntry> { - self.plugins.get(plugin_name) - } - - /// Resolve a single plugin's entries straight off cpex-core, with - /// no APL route-level overrides. Convenience for tests and for hosts - /// that wire the invoker without a `CompiledRoute` in scope (e.g. - /// adapters that invoke a single plugin imperatively). Returns - /// `None` if cpex-core has no entries for the plugin. - pub fn resolve_plugin(manager: &PluginManager, plugin_name: &str) -> Option { - let base_entries = manager.find_plugin_entries(plugin_name); - if base_entries.is_empty() { - return None; - } - let mut entries_by_hook: HashMap = HashMap::new(); - for (hook_name, entry) in base_entries { - entries_by_hook.insert(hook_name, entry); - } - Some(RoutePluginEntry { - plugin_name: plugin_name.to_string(), - entries_by_hook, - }) - } -} - -fn parse_on_error(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "fail" => Some(OnError::Fail), - "ignore" => Some(OnError::Ignore), - "disable" => Some(OnError::Disable), - _ => None, - } -} - -/// Recursively walk every effect node in an `Effect` tree, invoking -/// `visit` on each. Used by `collect_*_names` below to find Plugin / -/// Delegate references that may be nested inside `Effect::When`, -/// `Effect::Sequential`, `Effect::Parallel`, or `Effect::Pdp` reaction -/// lists. Previously these were flat — Step::Plugin lived directly under -/// policy: — so a simple iter() was enough; now the IR is tree- -/// shaped and the same scan needs recursion. -fn walk_effects(effects: &[Effect], visit: &mut F) { - for e in effects { - visit(e); - match e { - Effect::When { body, .. } => walk_effects(body, visit), - Effect::Sequential(inner) | Effect::Parallel(inner) => walk_effects(inner, visit), - Effect::Pdp { - on_allow, on_deny, .. - } => { - walk_effects(on_allow, visit); - walk_effects(on_deny, visit); - }, - _ => {}, - } - } -} - -/// Walk a `CompiledRoute` and return the unique delegate-plugin names -/// referenced by any `Effect::Delegate` anywhere in `policy` / -/// `post_policy` (including effects nested inside When / Sequential / -/// Parallel / Pdp reactions). Insertion-ordered for build determinism. -/// Separate from [`collect_plugin_names`] because delegate plugins -/// resolve under a different hook family (`token.delegate`) and the -/// dispatch plan keeps them in a separate map. -pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec { - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut visit = |e: &Effect| { - if let Effect::Delegate(ds) = e { - if seen.insert(ds.plugin_name.clone()) { - out.push(ds.plugin_name.clone()); - } - } - }; - walk_effects(&route.policy, &mut visit); - walk_effects(&route.post_policy, &mut visit); - out -} - -/// Walk a `CompiledRoute` and return the unique elicitation-plugin names -/// referenced by any `Effect::Elicit` anywhere in `policy` / -/// `post_policy` (including nested). Insertion-ordered for build -/// determinism. Separate from [`collect_plugin_names`] because -/// elicitation plugins resolve under the `elicit` hook family and the -/// plan keeps them in their own map. -pub(crate) fn collect_elicit_plugin_names(route: &CompiledRoute) -> Vec { - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut visit = |e: &Effect| { - if let Effect::Elicit(es) = e { - if seen.insert(es.plugin_name.clone()) { - out.push(es.plugin_name.clone()); - } - } - }; - walk_effects(&route.policy, &mut visit); - walk_effects(&route.post_policy, &mut visit); - out -} - -/// Walk a `CompiledRoute` and return the unique plugin names referenced -/// by any `Effect::Plugin` anywhere in `policy` / `post_policy` (including -/// nested) or `Stage::Plugin` (in `args` / `result` pipelines). -/// Insertion-ordered for build determinism. -pub(crate) fn collect_plugin_names(route: &CompiledRoute) -> Vec { - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut visit = |e: &Effect| { - if let Effect::Plugin { name } = e { - if seen.insert(name.clone()) { - out.push(name.clone()); - } - } - }; - walk_effects(&route.policy, &mut visit); - walk_effects(&route.post_policy, &mut visit); - for fr in route.args.iter().chain(route.result.iter()) { - for stage in &fr.pipeline.stages { - if let Stage::Plugin { name } = stage { - if seen.insert(name.clone()) { - out.push(name.clone()); - } - } - } - } - out -} - -/// Compute the union of capabilities declared by every plugin a -/// `CompiledRoute` can dispatch to (with per-route overrides applied). -/// -/// This is what the synthetic `AplRouteHandler`'s `PluginConfig.capabilities` -/// must be set to: cpex-core's executor filters the `Extensions` view -/// before invoking every plugin (including the synthetic one), so if -/// the handler has fewer capabilities than its inner plugins need, -/// downstream views get doubly-filtered and label/delegation mutations -/// fail monotonicity checks on the way back out. -/// -/// Plugins missing from the registry are silently skipped — the -/// dispatch plan will log a `warn!` and surface a `NotFound` at -/// invocation time, so config drift surfaces in the right place -/// rather than as a confusing capability gap. -pub(crate) fn route_capability_union( - route: &CompiledRoute, - registry: &PluginRegistry, -) -> std::collections::HashSet { - let mut caps: std::collections::HashSet = std::collections::HashSet::new(); - // Plugin steps (`plugin(name)` in policy / `plugin: name` in - // args / result pipelines). - for name in collect_plugin_names(route) { - if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { - for cap in eff.capabilities.as_slice() { - caps.insert(cap.clone()); - } - } - } - // Delegate steps (`delegate(name, ...)`). Without this, a - // delegator plugin that declares `capabilities: - // [read_inbound_credentials, write_delegated_tokens]` in YAML - // gets those stripped at the AplRouteHandler boundary — the - // synthetic handler doesn't union its caps in, so the executor - // filters out the inbound bearer before DelegationPluginInvoker - // dispatches, and the delegator handler sees an empty token. - // Hosts WANT to express per-plugin caps in YAML rather than - // widening the AplRouteHandler's baseline (which would leak - // those creds to every other step in the route). - for name in collect_delegate_plugin_names(route) { - if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { - for cap in eff.capabilities.as_slice() { - caps.insert(cap.clone()); - } - } - } - // Elicitation steps (`require_approval(name, ...)`, …) — same reason - // as delegation: an elicitation handler that declares e.g. - // `read_subject` (to read the approver identity) must not have it - // stripped at the AplRouteHandler boundary. - let elicit_plugins = collect_elicit_plugin_names(route); - for name in &elicit_plugins { - if let Some(eff) = EffectivePlugin::resolve(name, registry, &route.plugin_overrides) { - for cap in eff.capabilities.as_slice() { - caps.insert(cap.clone()); - } - } - } - // A route with an elicitation step needs `read_headers` on the synthetic - // handler so the `X-Policy-Elicitation-Id` retry header survives the - // capability filter and reaches the bag (Phase 5 retry-seeding). Without - // it, the `http` extension is stripped before the handler reads it and - // every retry re-dispatches a fresh elicitation instead of checking. - if !elicit_plugins.is_empty() { - caps.insert("read_headers".to_string()); - } - caps -} - -/// Host-owned dispatch cache. Construct once, share via `Arc` -/// across all `CmfPluginInvoker::for_request` calls so plans built for -/// one request can be reused by the next. -/// -/// Cache key is the APL `route_key`. Entries pair with the cpex-core -/// snapshot generation observed at build time; a mismatch on lookup -/// triggers eviction and rebuild. v0 keys on `route_key` only — -/// entity-aware caching (entity_type/entity_name from `MetaExtension`) -/// is a follow-up when per-tenant lineup variation lands. -#[derive(Default)] -pub struct DispatchCache { - inner: RwLock)>>, -} - -impl DispatchCache { - pub fn new() -> Self { - Self::default() - } - - /// Get-or-build a plan for the route. Read-locked fast path returns - /// the cached plan when the generation matches; otherwise drop the - /// read lock, rebuild, and write-lock-insert. The brief window - /// between read-miss and write-insert may let two concurrent - /// builders race — both produce identical plans and the second - /// insert just overwrites the first. Cheap relative to the cost of - /// the build itself, and avoids holding a write lock across the - /// build call. - /// - /// Async because `RouteDispatchPlan::build` may invoke - /// `PluginManager::build_override_entries`, which calls plugin - /// factories and `initialize()` for routes that declare `config:` - /// overrides. Routes with no overrides take a synchronous path - /// inside the manager (no `.await` does any real work), so the - /// async cost is zero for the common case. - pub async fn get_or_build( - &self, - route: &CompiledRoute, - registry: &PluginRegistry, - manager: &PluginManager, - ) -> Arc { - let current_gen = manager.config_generation(); - { - let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); - if let Some((stored_gen, plan)) = r.get(&route.route_key) { - if *stored_gen == current_gen { - return Arc::clone(plan); - } - } - } - let plan = Arc::new(RouteDispatchPlan::build(route, registry, manager).await); - let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner()); - w.insert(route.route_key.clone(), (current_gen, Arc::clone(&plan))); - plan - } -} diff --git a/crates/apl-cpex/src/elicitation_invoker.rs b/crates/apl-cpex/src/elicitation_invoker.rs deleted file mode 100644 index 3b1569f5..00000000 --- a/crates/apl-cpex/src/elicitation_invoker.rs +++ /dev/null @@ -1,232 +0,0 @@ -// Location: ./crates/apl-cpex/src/elicitation_invoker.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `ElicitationPluginInvoker` — `apl-core::ElicitationInvoker` impl -// bound to the `ElicitationHook` family. Drives dispatch off a -// pre-resolved [`RouteDispatchPlan::elicitation_entries`] and forwards -// to `PluginManager::invoke_entries::(...)`. -// -// # When this runs -// -// The apl-core evaluator calls one of the three trait methods per -// `Effect::Elicit` it walks, across the lifetime of one elicitation: -// -// * `dispatch(step, resolved_from)` — first arrival. Builds an -// `ElicitationPayload` in `Dispatch` mode and invokes the named -// handler, which registers the intent / opens the backchannel and -// returns the correlation id + pending metadata. -// * `check(step, id)` — every retry. `Check` mode; the handler reports -// the current status without blocking. -// * `validate(step, id)` — once resolved. `Validate` mode; the handler -// verifies the response is genuine. -// -// All three resolve the handler the same way delegation does — `name → -// entry` off the plan (`step.plugin_name`). Elicitation routes by plugin -// name, not `(kind, channel)`; `channel` is only an audit label. -// -// # Shared extensions -// -// Like `DelegationPluginInvoker`, this carries the request's shared -// `Extensions` so the handler can read identity (the approver, the -// subject) through the normal hook extensions argument. Get the handle -// via `CmfPluginInvoker::extensions_arc()`. - -use std::sync::Arc; - -use async_trait::async_trait; -use tokio::sync::Mutex; - -use cpex_core::elicitation::{ - ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, - ElicitationStatusKind, -}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::manager::PluginManager; - -use apl_core::step::{ - ElicitStep, ElicitationDispatch, ElicitationError, ElicitationInvoker, ElicitationOutcome, - ElicitationStatus, ElicitationValidation, -}; - -use crate::dispatch_plan::RouteDispatchPlan; - -/// Bridges APL elicitation-step dispatch (`require_approval(...)`, -/// `confirm(...)`, …) to CPEX `ElicitationHook` plugins. -pub struct ElicitationPluginInvoker { - manager: Arc, - /// Same `Arc>` as the CMF invoker for this request, - /// so the handler reads the same identity the policy evaluated against. - extensions: Arc>, - /// Pre-resolved per-route elicitation lineup (`name → entry`). - plan: Arc, -} - -impl ElicitationPluginInvoker { - /// Construct an invoker bound to the request's shared extensions and - /// the route's pre-resolved dispatch plan. Take the extensions Arc - /// from `CmfPluginInvoker::extensions_arc()` so this and the CMF - /// invoker see the same Extensions. - pub fn new( - manager: Arc, - extensions: Arc>, - plan: Arc, - ) -> Self { - Self { - manager, - extensions, - plan, - } - } - - /// Resolve the route's `elicit` entry for `plugin_name`, or - /// `NotFound` (which the evaluator's `on_error` then handles). Routes - /// that don't reference this plugin won't have it in the plan. - fn entry_for( - &self, - plugin_name: &str, - ) -> Result { - self.plan - .elicitation_entries - .get(plugin_name) - .cloned() - .ok_or_else(|| ElicitationError::NotFound(plugin_name.to_string())) - } - - /// Common dispatch: invoke the resolved entry with `payload`, returns - /// the resolved `ElicitationPayload` on allow, or an - /// `ElicitationError` on a handler deny / missing payload. `op` names - /// the operation (`"dispatch"` / `"check"` / `"validate"`) so a - /// failure reads accurately rather than always saying "dispatch". - async fn invoke( - &self, - op: &str, - plugin_name: &str, - payload: ElicitationPayload, - ) -> Result { - let entry = self.entry_for(plugin_name)?; - let current_extensions = self.extensions.lock().await.clone(); - - let (result, _bg) = self - .manager - .invoke_entries::( - std::slice::from_ref(&entry), - payload, - current_extensions, - None, - ) - .await; - - if !result.continue_processing { - let detail = result - .violation - .map(|v| format!("{}: {}", v.code, v.reason)) - .unwrap_or_else(|| "denied without violation detail".to_string()); - return Err(ElicitationError::Handler(format!( - "{op}: plugin `{plugin_name}` halted: {detail}" - ))); - } - - ElicitationPayload::from_pipeline_result(&result).ok_or_else(|| { - ElicitationError::Handler(format!( - "{op}: plugin `{plugin_name}` returned allow but no ElicitationPayload" - )) - }) - } -} - -/// Map a step's optional channel/purpose/scope/timeout onto the payload's -/// input builders. Shared by all three operations. -fn apply_step_inputs(mut payload: ElicitationPayload, step: &ElicitStep) -> ElicitationPayload { - if let Some(purpose) = &step.purpose { - payload = payload.with_purpose(purpose.clone()); - } - if let Some(scope) = &step.scope { - payload = payload.with_scope(scope.clone()); - } - if let Some(timeout) = &step.timeout { - payload = payload.with_timeout(timeout.clone()); - } - if let Some(channel) = &step.channel { - payload = payload.with_channel(channel.clone()); - } - payload -} - -#[async_trait] -impl ElicitationInvoker for ElicitationPluginInvoker { - async fn dispatch( - &self, - step: &ElicitStep, - resolved_from: &str, - ) -> Result { - let payload = apply_step_inputs( - ElicitationPayload::new(ElicitationOp::Dispatch, step.kind.as_str(), resolved_from), - step, - ); - let out = self.invoke("dispatch", &step.plugin_name, payload).await?; - - // The handler must mint an id on dispatch. - let id = out.id.ok_or_else(|| { - ElicitationError::Handler(format!( - "dispatch: plugin `{}` dispatched without returning an id", - step.plugin_name - )) - })?; - Ok(ElicitationDispatch { - id, - approver: out.approver, - intent_id: out.intent_id, - expires_at: out.expires_at, - }) - } - - async fn check( - &self, - step: &ElicitStep, - id: &str, - ) -> Result { - let payload = apply_step_inputs( - ElicitationPayload::new(ElicitationOp::Check, step.kind.as_str(), "") - .with_elicitation_id(id), - step, - ); - let out = self.invoke("check", &step.plugin_name, payload).await?; - - match out.status { - Some(ElicitationStatusKind::Pending) | None => Ok(ElicitationStatus::Pending), - Some(ElicitationStatusKind::Expired) => Ok(ElicitationStatus::Expired), - Some(ElicitationStatusKind::Resolved) => { - // Resolved must carry an outcome; default to Denied - // (fail-safe — never silently treat as approved). - let outcome = match out.outcome { - Some(ElicitationOutcomeKind::Approved) => ElicitationOutcome::Approved, - Some(ElicitationOutcomeKind::Denied) | None => ElicitationOutcome::Denied, - }; - Ok(ElicitationStatus::Resolved { outcome }) - }, - } - } - - async fn validate( - &self, - step: &ElicitStep, - id: &str, - ) -> Result { - let payload = apply_step_inputs( - ElicitationPayload::new(ElicitationOp::Validate, step.kind.as_str(), "") - .with_elicitation_id(id), - step, - ); - let out = self.invoke("validate", &step.plugin_name, payload).await?; - - Ok(ElicitationValidation { - // Absent `valid` is treated as not-valid (fail-closed). - valid: out.valid.unwrap_or(false), - approver: out.approver, - intent_id: out.intent_id, - reason: out.reason, - }) - } -} diff --git a/crates/apl-cpex/src/lib.rs b/crates/apl-cpex/src/lib.rs deleted file mode 100644 index 2cb99923..00000000 --- a/crates/apl-cpex/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -// Location: ./crates/apl-cpex/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// apl-cpex — bridge between APL evaluator (`apl-core`) and CPEX runtime -// (`cpex-core`). -// -// `apl-core::PluginInvoker` is string-typed by design (so `apl-core` -// stays free of CPEX deps). The actual typed boundary lives in this -// crate: one `PluginInvoker` implementation per `HookTypeDef`. The -// payload type is locked at the impl level — e.g. [`CmfPluginInvoker`] -// can only dispatch to CMF hooks because every internal call goes -// through `invoke_named::`, and the compiler enforces that -// the payload is `MessagePayload`. -// -// # v0 simplification — single-view-per-Message -// -// CMF distinguishes two messaging patterns: -// - LLM wire format — bundled multi-part Messages (thinking + text + -// tool_call(s)) — many MessageViews per Message. -// - Framework/protocol format (MCP, A2A, LangGraph) — single -// ContentPart per Message — one view per Message. -// -// v0 only handles request-side flows (outbound LLM call from the user, -// outbound MCP tools/call from the agent). Both are single-part, so the -// route → MessageView matching collapses to "one route fires per -// Message." When response-side handling lands, this assumption breaks -// and apl-core's route-matching layer needs to switch from -// routes-as-map to routes-as-list with a `match:` block filtering on -// MessageView attributes. See the APL implementation memory's -// "list-with-matchers" deferred item. - -pub mod attribute_source; -pub mod candidate_constraint; -pub mod cmf_invoker; -pub mod delegation_invoker; -pub mod dispatch_plan; -pub mod elicitation_invoker; -mod message_projection; -pub mod parallel_safety; -pub mod pdp_router; -pub mod register; -pub mod route_handler; -pub mod session_resolver; -pub mod session_store; -pub mod visitor; - -pub use attribute_source::{merge_attribute_docs, FileAttributeSource}; -pub use candidate_constraint::{fold_candidate_constraints, ConstraintConflict}; -pub use cmf_invoker::CmfPluginInvoker; -pub use delegation_invoker::DelegationPluginInvoker; -pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; -pub use elicitation_invoker::ElicitationPluginInvoker; -pub use pdp_router::PdpRouter; -pub use register::{register_apl, AplOptions}; -pub use route_handler::{ - AplRouteHandler, Phase, ELICITATION_APPROVED_CODE, ELICITATION_ID_HEADER, - ELICITATION_PEEK_HEADER, ELICITATION_PENDING_CODE, -}; -pub use session_store::{MemorySessionStore, SessionStore, SessionStoreError, SessionStoreFactory}; -pub use visitor::AplConfigVisitor; diff --git a/crates/apl-cpex/src/message_projection.rs b/crates/apl-cpex/src/message_projection.rs deleted file mode 100644 index ea567eec..00000000 --- a/crates/apl-cpex/src/message_projection.rs +++ /dev/null @@ -1,463 +0,0 @@ -// Location: ./crates/apl-cpex/src/message_projection.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor, Fred Araujo -// -// Projections between a CMF `Message` and the flat JSON APL evaluates -// against (`RoutePayload.args` / `RoutePayload.result`), plus their -// inverses. -// -// APL reasons about `args.` and `result.`; CMF carries -// typed content parts. These functions are the only translation between -// the two, and both consumers depend on them agreeing: -// -// * `AplRouteHandler` projects before evaluation and writes back -// after, so pipeline edits reach the host's body re-serializer. -// * `CmfPluginInvoker` projects a plugin-mutated message to read back -// the field a pipeline stage was focused on. -// -// Phase decides the side: Pre projects args, Post projects result. Each -// `write_*` is the inverse of the matching `extract_*`, so -// extract → write round-trips a message unchanged. -// -// The projections are lossy by design: they surface the one part APL -// addresses (a tool call's arguments, a tool result's content) and -// ignore the rest. That makes them unfit for answering "did anything -// change?" about a whole message — a mutation to any part they don't -// read is invisible. Callers needing that answer read the mutation -// signal the executor reports instead. - -use serde_json::Value; - -use cpex_core::cmf::{ContentPart, Message}; - -/// Rewrite the first text part of `msg` with `new_text`. If there is no -/// text part, append one. Mirrors what `MessagePayload`'s normal -/// modify-path does for single-view v0. -pub(crate) fn rewrite_message_text(msg: &mut Message, new_text: &str) { - for part in msg.content.iter_mut() { - if let ContentPart::Text { text } = part { - *text = new_text.to_string(); - return; - } - } - msg.content.push(ContentPart::Text { - text: new_text.to_string(), - }); -} - -/// Extract `RoutePayload.args` from a CMF message. v0 maps: -/// * First `ContentPart::ToolCall` → `arguments` map (Object) -/// * First `ContentPart::PromptRequest` → `arguments` map (Object) -/// * Else (text / no entity parts) → JSON String of text content -/// -/// `args.` APL paths target tool / prompt arguments directly. -/// For text-only messages we fall back to the v0 "args = whole text" -/// shape so `args.text` predicates keep working. -pub(crate) fn extract_args_from_message(msg: &Message) -> Value { - for part in &msg.content { - match part { - ContentPart::ToolCall { content } => { - return Value::Object( - content - .arguments - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - ); - }, - ContentPart::PromptRequest { content } => { - return Value::Object( - content - .arguments - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - ); - }, - _ => {}, - } - } - Value::String(msg.get_text_content()) -} - -/// Inverse of [`extract_args_from_message`]: write `args` back into -/// `msg`'s first ToolCall / PromptRequest argument map, or — for -/// text payloads — into the first text part. -/// -/// Silently no-ops when the args shape doesn't match the message -/// content shape (e.g. operator pipeline produced a String for what -/// was originally a ToolCall). The mismatch path is recoverable — -/// the upstream just sees the original unmodified content rather -/// than a malformed rewrite. -pub(crate) fn write_args_back_to_message(msg: &mut Message, args: &Value) { - for part in msg.content.iter_mut() { - match part { - ContentPart::ToolCall { content } => { - if let Some(obj) = args.as_object() { - content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - } - return; - }, - ContentPart::PromptRequest { content } => { - if let Some(obj) = args.as_object() { - content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - } - return; - }, - _ => {}, - } - } - // Fall through: no structured entity part — treat as text. - if let Some(text) = args.as_str() { - rewrite_message_text(msg, text); - } -} - -/// Extract `RoutePayload.result` from a CMF message. Mirror of -/// [`extract_args_from_message`] for the Post phase. v0 maps: -/// * First `ContentPart::ToolResult` → its `content` JSON value -/// * Else (text / no structured result part) → JSON String of text -/// -/// `result.` APL paths target the structured result directly. -pub(crate) fn extract_result_from_message(msg: &Message) -> Value { - for part in &msg.content { - if let ContentPart::ToolResult { content } = part { - return content.content.clone(); - } - } - Value::String(msg.get_text_content()) -} - -/// Inverse of [`extract_result_from_message`]: write a mutated -/// `result` back into the message's first `ContentPart::ToolResult.content`, -/// or — for text-only messages — into the first text part. The praxis -/// filter's response-body re-serializer then lifts the new content -/// out of the ContentPart and folds it back into the JSON-RPC -/// `result.content[*].text` payload. -pub(crate) fn write_result_back_to_message(msg: &mut Message, result: &Value) { - for part in msg.content.iter_mut() { - if let ContentPart::ToolResult { content } = part { - content.content = result.clone(); - return; - } - } - if let Some(text) = result.as_str() { - rewrite_message_text(msg, text); - } -} - -/// Apply to `base` only what changed between `pre` and `post`. -/// -/// `pre` and `post` bracket one editor's work (an APL pipeline: the -/// projection it started from, and the projection it produced). `base` -/// is the same projection taken from a payload a *different* editor (a -/// plugin) has since rewritten. Copying `post` over `base` wholesale -/// would discard the plugin's edits to keys the pipeline never touched, -/// so instead each differing leaf, added key, and removed key is applied -/// individually. -/// -/// When nothing else edited the payload, `base` equals `pre` and the -/// result is exactly `post`. -/// -/// Objects merge key by key; arrays and scalars are single values, so a -/// change to one replaces it whole. That matches how APL writes fields: -/// its dotted paths only traverse objects. -/// -/// # Precedence when both editors touched the same path -/// -/// **The pipeline wins.** If a plugin rewrote a key and the pipeline -/// rewrote it too, the pipeline's value lands; if the plugin *removed* a -/// key the pipeline then rewrote, the key comes back with the pipeline's -/// value; if the pipeline *omitted* a key the plugin had rewritten, the -/// key goes and the plugin's edit with it. Every case logs a warning -/// naming the key, because the losing edit is usually also a redaction -/// and a silent tie-break in this path is exactly the class of bug this -/// function exists to prevent. -/// -/// The rule is config-author-wins: an `args:` / `result:` pipeline is -/// written by the operator deploying the policy, so it outranks a -/// plugin's own view when the two genuinely conflict. Neither ordering -/// leaks plaintext (both editors write redacted values), but a coarse -/// pipeline stage can beat a finer plugin redaction, which is why the -/// conflict is logged rather than resolved silently. -pub(crate) fn apply_changed_paths(base: &mut Value, pre: &Value, post: &Value) { - let (Some(pre_map), Some(post_map)) = (pre.as_object(), post.as_object()) else { - // Not a keyed shape at this level, so there's nothing to merge - // per-key: the value either changed or it didn't. - if pre != post { - *base = post.clone(); - } - return; - }; - let Some(base_map) = base.as_object_mut() else { - // The other editor replaced the keyed shape with something else - // entirely. There's no key to merge into, so take this editor's - // view rather than invent a reconciliation. - tracing::warn!( - "payload projection changed shape under a field pipeline; \ - applying the pipeline's view and discarding the other edit" - ); - *base = post.clone(); - return; - }; - - for (key, pre_value) in pre_map { - if !post_map.contains_key(key) { - // Silent when the plugin left the key alone, which is the - // common case for an `omit`. - if base_map - .get(key) - .is_some_and(|base_value| base_value != pre_value) - { - tracing::warn!( - field = %key, - "pipeline omitted this field, discarding a plugin's edit to it" - ); - } - base_map.remove(key); - } - } - - for (key, post_value) in post_map { - match pre_map.get(key) { - // Untouched by this editor — leave whatever `base` holds, - // which is the whole point. - Some(pre_value) if pre_value == post_value => {}, - Some(pre_value) => { - let nested = base_map - .get_mut(key) - .filter(|base_value| base_value.is_object()) - .filter(|_| pre_value.is_object() && post_value.is_object()); - match nested { - Some(base_value) => apply_changed_paths(base_value, pre_value, post_value), - None => { - warn_on_conflict(base_map.get(key), pre_value, key); - base_map.insert(key.clone(), post_value.clone()); - }, - } - }, - None => { - // Both editors added the same key. Same precedence, same - // reason to say so out loud. - if base_map.contains_key(key) { - tracing::warn!( - field = %key, - "pipeline and plugin both added this field; keeping the pipeline's value" - ); - } - base_map.insert(key.clone(), post_value.clone()); - }, - } - } -} - -/// Log the two ways a pipeline edit can override a plugin's work on the -/// same key: the plugin changed it too, or the plugin removed it. Silent -/// in the common case where the plugin left the key alone. -fn warn_on_conflict(base_value: Option<&Value>, pre_value: &Value, key: &str) { - match base_value { - Some(base_value) if base_value != pre_value => tracing::warn!( - field = %key, - "pipeline edit overrides a plugin's edit to the same field; \ - keeping the pipeline's value" - ), - Some(_) => {}, - None => tracing::warn!( - field = %key, - "pipeline edit reinstates a field the plugin removed" - ), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::cmf::enums::Role; - use cpex_core::cmf::{ToolCall, ToolResult}; - - fn tool_call_message() -> Message { - Message::with_content( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [ - ("city".to_string(), serde_json::json!("London")), - ("units".to_string(), serde_json::json!("metric")), - ] - .into_iter() - .collect(), - namespace: None, - }, - }], - ) - } - - fn tool_result_message() -> Message { - Message::with_content( - Role::Tool, - vec![ContentPart::ToolResult { - content: ToolResult { - tool_call_id: "tc_001".to_string(), - tool_name: "get_weather".to_string(), - content: serde_json::json!({"temp": 12, "sky": "grey"}), - is_error: false, - }, - }], - ) - } - - #[test] - fn args_round_trip_leaves_a_tool_call_unchanged() { - let mut msg = tool_call_message(); - let args = extract_args_from_message(&msg); - write_args_back_to_message(&mut msg, &args); - assert_eq!(extract_args_from_message(&msg), args); - } - - #[test] - fn result_round_trip_leaves_a_tool_result_unchanged() { - let mut msg = tool_result_message(); - let result = extract_result_from_message(&msg); - write_result_back_to_message(&mut msg, &result); - assert_eq!(extract_result_from_message(&msg), result); - } - - #[test] - fn text_message_projects_to_its_whole_text_both_ways() { - let mut msg = Message::text(Role::User, "hello"); - assert_eq!( - extract_args_from_message(&msg), - serde_json::json!("hello"), - "a text-only message has no structured args, so args are the text" - ); - write_args_back_to_message(&mut msg, &serde_json::json!("goodbye")); - assert_eq!(msg.get_text_content(), "goodbye"); - } - - #[test] - fn changed_paths_apply_over_an_untouched_base() { - // Nobody else edited the payload, so base == pre and applying - // the changes must land exactly on post. - let pre = serde_json::json!({"city": "London", "units": "metric"}); - let post = serde_json::json!({"city": "[REDACTED]", "units": "metric"}); - let mut base = pre.clone(); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!(base, post); - } - - #[test] - fn changed_paths_preserve_another_editors_keys() { - let pre = serde_json::json!({"city": "London", "token": "sk-secret"}); - // The pipeline rewrote `city` only. - let post = serde_json::json!({"city": "[REDACTED]", "token": "sk-secret"}); - // Meanwhile a plugin rewrote `token`. - let mut base = serde_json::json!({"city": "London", "token": "[SCRUBBED]"}); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!( - base, - serde_json::json!({"city": "[REDACTED]", "token": "[SCRUBBED]"}), - "both editors' work must survive" - ); - } - - #[test] - fn changed_paths_apply_removals() { - let pre = serde_json::json!({"city": "London", "debug": true}); - let post = serde_json::json!({"city": "London"}); - let mut base = serde_json::json!({"city": "Paris", "debug": true}); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!(base, serde_json::json!({"city": "Paris"})); - } - - /// The removal counterpart of the same-key conflict: a pipeline `omit` - /// of a key the plugin had just rewritten still drops the key, and the - /// plugin's edit with it. Safe in either ordering (the key is gone - /// whoever wins), so this pins the outcome rather than a leak. - #[test] - fn changed_paths_omit_a_key_the_plugin_rewrote() { - let pre = serde_json::json!({"city": "London", "ssn": "123-45-6789"}); - let post = serde_json::json!({"city": "London"}); - let mut base = serde_json::json!({"city": "London", "ssn": "[REDACTED]"}); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!( - base, - serde_json::json!({"city": "London"}), - "an omitted key must not survive because a plugin rewrote it" - ); - } - - #[test] - fn changed_paths_recurse_into_nested_objects() { - let pre = serde_json::json!({"user": {"name": "ada", "ssn": "123-45-6789"}}); - let post = serde_json::json!({"user": {"name": "ada", "ssn": "[REDACTED]"}}); - let mut base = serde_json::json!({"user": {"name": "ADA", "ssn": "123-45-6789"}}); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!( - base, - serde_json::json!({"user": {"name": "ADA", "ssn": "[REDACTED]"}}), - "a sibling edit inside the same object must not be clobbered" - ); - } - - /// Documented precedence: when both editors rewrote the same key, the - /// pipeline's value lands. Pinned so a future change to the rule is - /// deliberate rather than incidental. - #[test] - fn changed_paths_let_the_pipeline_win_a_same_key_conflict() { - let pre = serde_json::json!({"ssn": "123-45-6789"}); - let post = serde_json::json!({"ssn": "***-**-6789"}); - // The plugin redacted the same key more aggressively. - let mut base = serde_json::json!({"ssn": "[REDACTED]"}); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!(base, serde_json::json!({"ssn": "***-**-6789"})); - } - - /// A key the plugin removed comes back when the pipeline rewrote it, - /// carrying the pipeline's (redacted) value. - #[test] - fn changed_paths_reinstate_a_key_the_plugin_removed() { - let pre = serde_json::json!({"ssn": "123-45-6789", "name": "Ada"}); - let post = serde_json::json!({"ssn": "[REDACTED]", "name": "Ada"}); - let mut base = serde_json::json!({"name": "Ada"}); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!( - base, - serde_json::json!({"ssn": "[REDACTED]", "name": "Ada"}), - "the pipeline's redaction lands even though the plugin dropped the key" - ); - } - - #[test] - fn changed_paths_replace_a_scalar_projection_whole() { - let mut base = serde_json::json!("plugin text"); - apply_changed_paths( - &mut base, - &serde_json::json!("original"), - &serde_json::json!("pipeline text"), - ); - assert_eq!(base, serde_json::json!("pipeline text")); - } - - #[test] - fn changed_paths_take_the_pipeline_view_when_base_shape_differs() { - let pre = serde_json::json!({"city": "London"}); - let post = serde_json::json!({"city": "[REDACTED]"}); - let mut base = serde_json::json!("no longer an object"); - apply_changed_paths(&mut base, &pre, &post); - assert_eq!(base, post); - } - - #[test] - fn shape_mismatch_leaves_a_tool_call_untouched() { - let mut msg = tool_call_message(); - let before = extract_args_from_message(&msg); - // A pipeline that produced a string where the message holds - // structured arguments: better to forward the original than a - // malformed rewrite. - write_args_back_to_message(&mut msg, &serde_json::json!("not an object")); - assert_eq!(extract_args_from_message(&msg), before); - } -} diff --git a/crates/apl-cpex/src/parallel_safety.rs b/crates/apl-cpex/src/parallel_safety.rs deleted file mode 100644 index 1482e712..00000000 --- a/crates/apl-cpex/src/parallel_safety.rs +++ /dev/null @@ -1,333 +0,0 @@ -// Location: ./crates/apl-cpex/src/parallel_safety.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Route-compile-time plugin-mode validation for APL `parallel:` blocks. -// -// `apl-core::Effect::validate_parallel_purity` already rejects FieldOp / -// Delegate at the IR level — those are statically detectable without -// any plugin knowledge. Plugin calls (`Effect::Plugin { name }`) need -// a second pass because their concurrency-safety depends on each -// plugin's registered `PluginMode` — information that lives in the -// PluginManager, not the IR. -// -// Lives in apl-cpex because: -// * apl-core can't see plugin modes (plugin-agnostic by design) -// * The PluginManager is constructed in the host integration, not in -// apl-core's compiler -// * The visitor that turns YAML routes into `CompiledRoute`s is the -// natural place to run all post-IR-level validations together -// -// # Mode rules -// -// Allowed inside `parallel:`: -// - `Audit` — read-only by declaration -// - `Concurrent` — explicitly designed for parallel execution -// - `FireAndForget` — side-effects only, no return value to merge -// - `Disabled` — skipped at runtime anyway -// -// Rejected inside `parallel:`: -// - `Sequential` — `can_modify() == true`, would silently lose its mutation -// - `Transform` — same as Sequential for our purposes -// -// The asymmetry exists because parallel branches each get a *cloned* -// bag and payload; any mutation a branch makes lives only inside its -// clone. Plugins authored under Sequential / Transform semantics -// reasonably assume their writes persist. Detecting the misuse at -// route-compile means the operator sees a clear error instead of a -// confusing "but my plugin ran and the bag didn't change" runtime -// surprise. - -use apl_core::rules::{CompiledRoute, Effect}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::PluginMode; - -/// Read-only "what mode is plugin X registered with" lookup, used by -/// the validator. A trait (rather than a `&PluginManager`) so: -/// -/// * Tests can pass a small HashMap-backed mock without constructing -/// a real `PluginManager` (which requires plugin registration and -/// a bunch of cpex-core internal types). -/// * Future consumers that store plugin modes in a different shape -/// (e.g. a separate config catalogue) plug in without forcing them -/// to back the lookup with a full PluginManager. -pub trait PluginModeLookup { - /// Returns the mode for `name`, or `None` if no plugin by that - /// name is registered. - fn mode_for(&self, name: &str) -> Option; -} - -impl PluginModeLookup for PluginManager { - fn mode_for(&self, name: &str) -> Option { - self.get_plugin(name).map(|p| p.mode()) - } -} - -/// Walk a compiled route looking for `Effect::Plugin` calls nested -/// inside any `Effect::Parallel` block, and check that each named -/// plugin's registered mode is safe for parallel execution. -/// -/// Returns `Ok(())` if all plugins inside parallel blocks have safe -/// modes (or the route has no parallel blocks). On failure, returns a -/// `;`-separated list of every violation found — running a single pass -/// over the route surfaces all problems at once instead of stopping -/// at the first. -pub fn validate_parallel_plugin_modes( - route: &CompiledRoute, - registry: &L, -) -> Result<(), String> { - let mut errors: Vec = Vec::new(); - for (phase_name, effects) in [ - ("pre_invocation", route.policy.as_slice()), - ("post_invocation", route.post_policy.as_slice()), - ] { - for (idx, effect) in effects.iter().enumerate() { - walk_effect( - effect, - &format!("routes.{}.{}[{}]", route.route_key, phase_name, idx), - false, - registry, - &mut errors, - ); - } - } - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } -} - -/// Recursive traversal. `under_parallel` is true once we've descended -/// into a `Parallel` node; from then on every `Plugin` we hit gets -/// checked against the mode allowlist. Nested `Parallel`/`Sequential` -/// both keep the flag true (a sequential block inside a parallel one -/// is still ultimately running in the parallel branch's cloned state). -fn walk_effect( - effect: &Effect, - location: &str, - under_parallel: bool, - registry: &L, - errors: &mut Vec, -) { - match effect { - Effect::Plugin { name } if under_parallel => { - check_plugin_mode(name, location, registry, errors); - }, - Effect::Parallel(inner) => { - for e in inner { - walk_effect(e, location, true, registry, errors); - } - }, - Effect::Sequential(inner) => { - for e in inner { - walk_effect(e, location, under_parallel, registry, errors); - } - }, - Effect::When { body, .. } => { - // A `when:` body inherits the parallel context of its - // enclosing scope. Plugin calls inside `when:` under a - // `parallel:` are still subject to the mode check. - for e in body { - walk_effect(e, location, under_parallel, registry, errors); - } - }, - Effect::Pdp { - on_allow, on_deny, .. - } => { - for e in on_allow.iter().chain(on_deny.iter()) { - walk_effect(e, location, under_parallel, registry, errors); - } - }, - // Other variants (Allow/Deny/Plugin-not-in-parallel/Delegate/ - // Taint/FieldOp) don't carry nested effects today. Note that - // `Delegate` / `FieldOp` inside Parallel was already rejected - // by `apl-core::Effect::validate_parallel_purity` at parse - // time — no need to re-check here. - _ => {}, - } -} - -fn check_plugin_mode( - name: &str, - location: &str, - registry: &L, - errors: &mut Vec, -) { - let mode = match registry.mode_for(name) { - Some(m) => m, - None => { - errors.push(format!( - "{}: `parallel:` references unknown plugin `{}`", - location, name - )); - return; - }, - }; - if !is_safe_in_parallel(mode) { - errors.push(format!( - "{}: plugin `{}` has mode `{}` which can modify state; parallel \ - branches discard mutations, so this would silently lose its effect. \ - Use `sequential:` for ordered mutations or change the plugin's mode.", - location, name, mode, - )); - } -} - -/// Allowlist check. Centralised so the rule is documented in one -/// place and easy to find if `PluginMode` gains a new variant. -fn is_safe_in_parallel(mode: PluginMode) -> bool { - matches!( - mode, - PluginMode::Audit - | PluginMode::Concurrent - | PluginMode::FireAndForget - | PluginMode::Disabled - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use apl_core::rules::Expression; - use std::collections::HashMap; - - /// Test mock — a plain `HashMap`. Implements the - /// lookup trait without needing the real cpex-core registry's - /// plugin / hook registration machinery. - struct MockLookup(HashMap); - - impl MockLookup { - fn new() -> Self { - Self(HashMap::new()) - } - fn with(mut self, name: &str, mode: PluginMode) -> Self { - self.0.insert(name.to_string(), mode); - self - } - } - - impl PluginModeLookup for MockLookup { - fn mode_for(&self, name: &str) -> Option { - self.0.get(name).copied() - } - } - - fn route_with_policy(effects: Vec) -> CompiledRoute { - let mut r = CompiledRoute::new("test_route"); - r.policy = effects; - r - } - - fn rule(effects: Vec) -> Effect { - Effect::When { - condition: Expression::Always, - body: effects, - source: "test".into(), - } - } - - fn parallel_plugin(name: &str) -> Effect { - Effect::Parallel(vec![Effect::Plugin { name: name.into() }]) - } - - #[test] - fn audit_plugin_in_parallel_is_accepted() { - let reg = MockLookup::new().with("audit_logger", PluginMode::Audit); - let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]); - assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); - } - - #[test] - fn concurrent_plugin_in_parallel_is_accepted() { - let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent); - let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]); - assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); - } - - #[test] - fn fire_and_forget_in_parallel_is_accepted() { - let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget); - let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]); - assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); - } - - #[test] - fn sequential_plugin_in_parallel_is_rejected() { - let reg = MockLookup::new().with("mutator", PluginMode::Sequential); - let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]); - let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("mutator"), "names plugin: {}", err); - assert!(err.contains("sequential"), "names mode: {}", err); - assert!(err.contains("`sequential:`"), "suggests fix: {}", err); - } - - #[test] - fn transform_plugin_in_parallel_is_rejected() { - let reg = MockLookup::new().with("redactor", PluginMode::Transform); - let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]); - let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("transform")); - } - - #[test] - fn unknown_plugin_in_parallel_is_rejected() { - let reg = MockLookup::new(); - let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]); - let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("unknown plugin")); - assert!(err.contains("ghost")); - } - - #[test] - fn sequential_plugin_outside_parallel_is_allowed() { - // The same Sequential-mode plugin is fine at the top level — - // only its appearance INSIDE a parallel block is the problem. - let reg = MockLookup::new().with("mutator", PluginMode::Sequential); - let route = route_with_policy(vec![rule(vec![Effect::Plugin { - name: "mutator".into(), - }])]); - assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); - } - - #[test] - fn nested_sequential_inside_parallel_still_validates_plugins() { - // `parallel: [sequential: [plugin(seq_mode)]]` — the sequential - // is just a grouping construct; the plugin still runs inside - // the parallel branch's cloned state. - let reg = MockLookup::new().with("mutator", PluginMode::Sequential); - let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![ - Effect::Sequential(vec![Effect::Plugin { - name: "mutator".into(), - }]), - ])])]); - let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("mutator")); - } - - #[test] - fn multiple_violations_all_reported() { - // Surface every violation in one pass so the operator can fix - // them all at once instead of one error per build cycle. - let reg = MockLookup::new() - .with("a", PluginMode::Sequential) - .with("b", PluginMode::Transform); - let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![ - Effect::Plugin { name: "a".into() }, - Effect::Plugin { name: "b".into() }, - ])])]); - let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("`a`"), "names a: {}", err); - assert!(err.contains("`b`"), "names b: {}", err); - } - - #[test] - fn post_policy_phase_is_validated_too() { - let reg = MockLookup::new().with("mutator", PluginMode::Sequential); - let mut route = CompiledRoute::new("test_route"); - route.post_policy = vec![rule(vec![parallel_plugin("mutator")])]; - let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("post_invocation")); - } -} diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs deleted file mode 100644 index 03c4769e..00000000 --- a/crates/apl-cpex/src/pdp_router.rs +++ /dev/null @@ -1,218 +0,0 @@ -// Location: ./crates/apl-cpex/src/pdp_router.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `PdpRouter` — composite `PdpResolver` that dispatches each call to the -// resolver matching the requested `PdpDialect`. Lets a single host (or a -// single `AplRouteHandler`) carry resolvers for several backends at the -// same time without having to pick one at construction. -// -// The PDP backends that ship in this workspace, each its own crate -// registered here by dialect: -// -// - **cedar** (`cpex-pdp-cedar-direct`) — in-process Cedar policy-set -// evaluation. -// - **opa** — Open Policy Agent / Rego. -// - **authzen** — AuthZen-protocol external decision point. -// - **nemo** — NeMo reasoning backend. -// - **cel** (`cpex-pdp-cel`) — inline CEL boolean predicates authored in -// the route YAML (`cel: { expr: "..." }`); smallest dep tree, no -// external policy store. -// -// Routing is by dialect equality. The first registered resolver for a -// given dialect wins on duplicate registration — registering Cedar twice -// keeps the original and logs a warning. Unknown-dialect calls return -// `PdpError::NoResolver(dialect)`. -// -// `PdpRouter` is itself a `PdpResolver`, so it slots straight into -// `AplRouteHandler::with_pdp`. Its own `dialect()` method returns -// `PdpDialect::Custom("router")` — a sentinel the evaluator doesn't -// branch on; only inner resolvers' dialects matter. - -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; - -use apl_core::attributes::AttributeBag; -use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; - -/// Dispatches PDP calls to the right resolver based on -/// `Step::Pdp.call.dialect`. Construct with `new()`, add resolvers via -/// `register`, then hand the router to a route handler. -/// -/// Cloning is cheap (refcount bumps on each resolver `Arc`) — the -/// `AplConfigVisitor` snapshots its accumulated router into an `Arc` -/// for every installed route handler so a config reload that mutates -/// the visitor state doesn't tear in-flight handlers. -#[derive(Clone)] -pub struct PdpRouter { - resolvers: HashMap>, -} - -impl PdpRouter { - pub fn new() -> Self { - Self { - resolvers: HashMap::new(), - } - } - - /// Register a resolver for its declared dialect. If a resolver is - /// already registered for that dialect the new one is dropped and a - /// warning is logged — explicit replacement should go through - /// `replace` instead so the intent is visible at call sites. - pub fn register(&mut self, resolver: Arc) -> &mut Self { - let dialect = resolver.dialect(); - if self.resolvers.contains_key(&dialect) { - tracing::warn!( - dialect = ?dialect, - "PdpRouter: resolver for dialect already registered — keeping existing", - ); - return self; - } - self.resolvers.insert(dialect, resolver); - self - } - - /// Replace any existing resolver for the new resolver's dialect. - /// Use this when the host genuinely wants to swap in a different - /// implementation (testing, A/B rollout). - pub fn replace(&mut self, resolver: Arc) -> &mut Self { - let dialect = resolver.dialect(); - self.resolvers.insert(dialect, resolver); - self - } - - /// Number of registered resolvers. Useful for tests. - pub fn len(&self) -> usize { - self.resolvers.len() - } - - pub fn is_empty(&self) -> bool { - self.resolvers.is_empty() - } -} - -impl Default for PdpRouter { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl PdpResolver for PdpRouter { - fn dialect(&self) -> PdpDialect { - // Sentinel — evaluator routes per `Step::Pdp.call.dialect`, not - // the resolver's own declared dialect. The router never claims to - // be one of the real dialects so a stray equality check can't - // accidentally pick it. - PdpDialect::Custom("router".to_string()) - } - - async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result { - let resolver = self - .resolvers - .get(&call.dialect) - .ok_or_else(|| PdpError::NoResolver(call.dialect.clone()))?; - resolver.evaluate(call, bag).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use apl_core::evaluator::Decision; - - struct FakePdp { - dialect: PdpDialect, - decision: Decision, - } - - #[async_trait] - impl PdpResolver for FakePdp { - fn dialect(&self) -> PdpDialect { - self.dialect.clone() - } - - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: self.decision.clone(), - diagnostics: Vec::new(), - }) - } - } - - #[tokio::test] - async fn routes_by_dialect() { - let mut router = PdpRouter::new(); - router.register(Arc::new(FakePdp { - dialect: PdpDialect::Cedar, - decision: Decision::Allow, - })); - router.register(Arc::new(FakePdp { - dialect: PdpDialect::Opa, - decision: Decision::Deny { - reason: Some("opa says no".into()), - rule_source: "opa".into(), - }, - })); - - let bag = AttributeBag::default(); - let cedar_call = PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::Value::Null, - }; - let opa_call = PdpCall { - dialect: PdpDialect::Opa, - args: serde_yaml::Value::Null, - }; - - let cedar_res = router.evaluate(&cedar_call, &bag).await.unwrap(); - assert!(matches!(cedar_res.decision, Decision::Allow)); - - let opa_res = router.evaluate(&opa_call, &bag).await.unwrap(); - assert!(matches!(opa_res.decision, Decision::Deny { .. })); - } - - #[tokio::test] - async fn missing_dialect_returns_no_resolver() { - let router = PdpRouter::new(); - let bag = AttributeBag::default(); - let call = PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::Value::Null, - }; - let err = router.evaluate(&call, &bag).await.unwrap_err(); - assert!(matches!(err, PdpError::NoResolver(_))); - } - - #[tokio::test] - async fn duplicate_register_keeps_first() { - let mut router = PdpRouter::new(); - router.register(Arc::new(FakePdp { - dialect: PdpDialect::Cedar, - decision: Decision::Allow, - })); - router.register(Arc::new(FakePdp { - dialect: PdpDialect::Cedar, - decision: Decision::Deny { - reason: Some("shouldn't fire".into()), - rule_source: "test".into(), - }, - })); - let call = PdpCall { - dialect: PdpDialect::Cedar, - args: serde_yaml::Value::Null, - }; - let res = router - .evaluate(&call, &AttributeBag::default()) - .await - .unwrap(); - assert!(matches!(res.decision, Decision::Allow)); - } -} diff --git a/crates/apl-cpex/src/register.rs b/crates/apl-cpex/src/register.rs deleted file mode 100644 index 63b39ff0..00000000 --- a/crates/apl-cpex/src/register.rs +++ /dev/null @@ -1,191 +0,0 @@ -// Location: ./crates/apl-cpex/src/register.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `register_apl` — sugar function that bundles "construct -// `AplConfigVisitor` + register it with the manager" into one call. -// -// Hosts that just want APL governance with sensible defaults call this -// instead of building the visitor by hand. The lower-level -// `PluginManager::register_visitor` API stays available for custom -// orchestrators (future Rego, Cedar-direct, hand-rolled audit visitors) -// that don't fit the APL setup. -// -// # Two ways to supply PDPs -// -// PDP resolvers can reach the visitor's internal `PdpRouter` via two -// channels, and `AplOptions` exposes both: -// -// * `pdps` — code-supplied resolvers. The host built them -// in Rust (e.g. a hand-rolled audit resolver, -// a test fake) and hands them in directly. -// * `pdp_factories` — factories the visitor consults when it sees a -// `global.apl.pdp[]` entry in the unified -// config. Each factory advertises a `kind()` -// string that matches the YAML block's `kind:` -// field. -// -// Both channels feed the same `PdpRouter` inside the visitor, so a -// host can mix the two freely — code-supplied Cedar for tests plus a -// config-declared OPA in prod, say. - -use std::collections::HashSet; -use std::sync::Arc; - -use cpex_core::manager::PluginManager; -use cpex_core::visitor::ConfigVisitor; - -use apl_core::step::{PdpFactory, PdpResolver}; - -use crate::dispatch_plan::DispatchCache; -use crate::session_store::{SessionStore, SessionStoreFactory}; -use crate::visitor::AplConfigVisitor; - -/// Configuration for [`register_apl`]. All runtime collaborators APL -/// needs to do its work are funneled through here so the call site -/// reads as a single block instead of a multi-step builder. -pub struct AplOptions { - /// Shared dispatch-plan cache. One `Arc` per host - /// instance — clones are cheap (refcount bump) and the cache is - /// internally synchronized. - pub dispatch_cache: Arc, - - /// Pluggable session-scoped state. `MemorySessionStore` is the - /// default in-process backend; production hosts swap in Redis / - /// DynamoDB-backed impls. - pub session_store: Arc, - - /// Zero or more code-supplied PDP resolvers. Each is registered - /// into the visitor's internal `PdpRouter`, so `pdp(...)` steps - /// dispatch by dialect across this list **and** any resolvers the - /// visitor builds from `global.apl.pdp[]` config entries. An empty - /// list combined with empty `pdp_factories` means no PDP is wired - /// — routes that call `pdp(...)` surface `PdpError::NoResolver` at - /// evaluation time, which is the correct behavior for "you forgot - /// to configure your policy decision point." - pub pdps: Vec>, - - /// PDP factories the visitor consults when it encounters a - /// `global.apl.pdp[]` entry. Each factory advertises a `kind()` - /// string that matches the YAML block's `kind:` field — e.g. - /// `cedar-direct`, `opa`. An empty list disables - /// config-driven PDP wiring; hosts can still supply resolvers via - /// `pdps`. - pub pdp_factories: Vec>, - - /// Session-store factories the visitor consults when it encounters a - /// `global.apl.session_store` block. Each factory advertises a - /// `kind()` string matching the block's `kind:` field — e.g. - /// `valkey`. An empty list keeps the constructor-supplied - /// `session_store` (the `MemorySessionStore` default) active, so - /// existing deployments are unaffected. - pub session_store_factories: Vec>, - - /// Override the visitor's baseline capabilities for installed - /// `AplRouteHandler`s. `None` uses the visitor's default - /// (read-only across the common attribute namespaces); `Some(set)` - /// replaces it entirely. The per-route plugin capability union is - /// added on top regardless — this only controls the baseline. - /// - /// Set to `Some(HashSet::new())` for strict deployments where - /// only plugin-declared caps should be granted. - pub base_capabilities: Option>, -} - -impl AplOptions { - /// Minimal options — in-process dispatch cache + memory session - /// store, no PDP, default baseline capabilities. Useful for tests - /// and single-process demos. - pub fn in_process() -> Self { - Self { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(crate::session_store::MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - } - } -} - -/// Build an [`AplConfigVisitor`] from the supplied options and register -/// it on the manager. Returns the `Arc` so the caller -/// can stash it for later inspection (or call `register_pdp` on it -/// after the fact for late-bound resolvers) — but in the typical case -/// the return value is dropped and the visitor lives inside the -/// manager's visitor list. -/// -/// After this call, the next `mgr.load_config_yaml(yaml)` invocation -/// will walk the visitor: cpex-core's [`visit_plugins`][vp] populates -/// the APL plugin registry from `&[PluginConfig]`; `visit_global` -/// processes any `global.apl.pdp[]` entries by dispatching to the -/// registered `pdp_factories`; the hierarchy walk stacks `global.apl` -/// / `defaults..apl` / `policies..apl` / route-level -/// `apl:` into compiled routes; one `AplRouteHandler` is installed -/// per route per phase via [`PluginManager::annotate_route`][ar]. -/// -/// [vp]: cpex_core::visitor::ConfigVisitor::visit_plugins -/// [ar]: cpex_core::manager::PluginManager::annotate_route -/// -/// # Example -/// -/// ```ignore -/// use std::sync::Arc; -/// use cpex_core::manager::PluginManager; -/// use apl_cpex::{register_apl, AplOptions}; -/// use cpex_pdp_cedar_direct::CedarDirectPdpFactory; -/// -/// let mgr = Arc::new(PluginManager::default()); -/// mgr.register_factory("scope-gate", Box::new(ScopeGateFactory)); -/// -/// apl_cpex::register_apl(&mgr, AplOptions { -/// dispatch_cache: dispatch_cache.clone(), -/// session_store: session_store.clone(), -/// pdps: vec![], // none code-supplied -/// pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], -/// base_capabilities: None, -/// }); -/// -/// mgr.load_config_yaml(&yaml_string)?; -/// mgr.initialize().await?; -/// ``` -pub fn register_apl(mgr: &Arc, opts: AplOptions) -> Arc { - let AplOptions { - dispatch_cache, - session_store, - pdps, - pdp_factories, - session_store_factories, - base_capabilities, - } = opts; - - // Build the visitor and apply consuming builders first (these take - // `self` by value), then mutating registrations (`&mut self` for - // factories), and finally wrap in `Arc` so we can hand the shared - // handle to the manager. Code-supplied PDPs go through - // `register_pdp(&self, ...)` which uses interior mutability, so - // they're registered after the `Arc` wrap. - let mut visitor = AplConfigVisitor::new(dispatch_cache, session_store, Arc::downgrade(mgr)); - - if let Some(caps) = base_capabilities { - visitor = visitor.with_base_capabilities(caps); - } - - for factory in pdp_factories { - visitor.register_pdp_factory(factory); - } - - for factory in session_store_factories { - visitor.register_session_store_factory(factory); - } - - let arc = Arc::new(visitor); - - for pdp in pdps { - arc.register_pdp(pdp); - } - - mgr.register_visitor(Arc::clone(&arc) as Arc); - arc -} diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs deleted file mode 100644 index fa7085b8..00000000 --- a/crates/apl-cpex/src/route_handler.rs +++ /dev/null @@ -1,871 +0,0 @@ -// Location: ./crates/apl-cpex/src/route_handler.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor, Fred Araujo -// -// `AplRouteHandler` — synthetic plugin that drives APL evaluation when -// cpex-core's `filter_entries_by_route` matches an annotated route. Each -// instance is bound to ONE phase (Pre or Post) so the unified-config -// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry -// distinct handler logic without an in-handler hook-name discriminator. -// -// # Why a phase-bound handler -// -// The CPEX manager's annotation table is keyed on -// `(entity_type, entity_name, scope, hook_name)`. The visitor registers -// one handler per route per phase; the manager picks the right one based -// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is -// needed — the handler already knows which phase it's running. -// -// # Lifetime / weak manager handle -// -// The handler holds `Weak` because the manager owns the -// snapshot that owns the annotation that owns the handler — a strong -// reference would create a cycle. Each `invoke` upgrades to `Arc` for -// the duration of the call. If the upgrade fails (manager has been -// dropped) the call returns a configuration error. - -use std::sync::{Arc, Weak}; - -use async_trait::async_trait; -use serde_json::Value; - -use cpex_core::cmf::MessagePayload; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::executor::ErasedResultFields; -use cpex_core::extensions::Extensions; -use cpex_core::hooks::PluginPayload; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; -use cpex_core::registry::AnyHookHandler; - -use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS}; -use apl_cmf::{extract_args, extract_result, BagBuilder}; -use apl_core::evaluator::Decision; -use apl_core::plugin_decl::PluginRegistry; -use apl_core::AttributeTree; - -use crate::candidate_constraint::fold_candidate_constraints; -use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload}; -use apl_core::rules::{CompiledRoute, DenyResponse}; -use apl_core::step::PdpResolver; - -use crate::cmf_invoker::CmfPluginInvoker; -use crate::delegation_invoker::DelegationPluginInvoker; -use crate::dispatch_plan::DispatchCache; -use crate::elicitation_invoker::ElicitationPluginInvoker; -use crate::message_projection::{ - apply_changed_paths, extract_args_from_message, extract_result_from_message, - write_args_back_to_message, write_result_back_to_message, -}; -use crate::pdp_router::PdpRouter; -use crate::session_store::SessionStore; - -/// JSON-RPC error code the host emits when a phase suspends on a pending -/// elicitation: "request not complete — retry echoing the elicitation id." -/// In the application-reserved JSON-RPC range; carried via -/// `PluginViolation::proto_error_code` for the host to put on the wire. -/// The agent SDK keys its pause/resume loop on this code. -pub const ELICITATION_PENDING_CODE: i64 = -32120; - -/// Header an agent echoes on retry to continue a suspended elicitation — -/// its value is the `elicitation_id` from a prior `-32120`. The handler -/// seeds it into the bag (`elicitation.id`) before evaluation so the -/// runtime *checks* the existing elicitation instead of dispatching a new -/// one. Mirrors how `X-User-Token` carries request-scoped context. -pub const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id"; - -/// JSON-RPC error code emitted when an agent re-checks an approval in -/// *peek* mode and it has resolved approved: "approved — confirm to apply." -/// The phase does NOT forward to the tool; the agent confirms with the -/// requester and re-sends *without* the peek header to actually run it. -/// Lets a human authorize while the requester separately commits execution. -pub const ELICITATION_APPROVED_CODE: i64 = -32121; - -/// Header an agent sets (alongside `X-Policy-Elicitation-Id`) to *peek* at an -/// approval — resolve its status without committing the action. Truthy -/// value ("1"/"true"/anything non-empty) enables it. -pub const ELICITATION_PEEK_HEADER: &str = "X-Policy-Elicitation-Peek"; - -/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post -/// covers `result` + `post_invocation`. Set once at construction and never -/// changes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Phase { - Pre, - Post, -} - -/// Synthetic plugin that drives APL evaluation for one route + one phase. -/// -/// Implements `Plugin` (so cpex-core treats it like any other plugin — -/// mode/capabilities/on_error come from the `PluginConfig` the visitor -/// supplied at `annotate_route` time) and `AnyHookHandler` (so the -/// executor dispatches into it through the normal type-erased path). -pub struct AplRouteHandler { - config: PluginConfig, - route: Arc, - phase: Phase, - plugin_registry: Arc, - dispatch_cache: Arc, - session_store: Arc, - /// Weak handle to the manager so we can resolve plugin entries + - /// dispatch into them by-name. `Weak` avoids the - /// manager↔snapshot↔annotation↔handler cycle. - manager: Weak, - /// PDP resolver. APL routes that don't use `pdp(...)` steps never - /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)` - /// step against an unregistered dialect returns - /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc. - /// install resolvers via [`Self::with_pdp`] or - /// [`Self::with_pdp_router`]. - pdp: Arc, - /// Static `data.*` attribute tree, flattened into every request's - /// bag. Shared `Arc` (the visitor hands the same tree to every - /// handler); empty by default when no source was configured. - attribute_tree: Arc, -} - -impl AplRouteHandler { - /// Build a handler. Visitor calls this twice per route — once for - /// each phase — and passes the resulting `Arc` to `annotate_route`. - pub fn new( - config: PluginConfig, - route: Arc, - phase: Phase, - plugin_registry: Arc, - dispatch_cache: Arc, - session_store: Arc, - manager: Weak, - ) -> Self { - Self { - config, - route, - phase, - plugin_registry, - dispatch_cache, - session_store, - manager, - pdp: Arc::new(PdpRouter::new()), - attribute_tree: Arc::new(apl_core::AttributeTree::empty()), - } - } - - /// Install the static `data.*` attribute tree flattened into every - /// request's bag. Defaults to empty; the visitor sets it from the - /// configured [`AttributeSource`](apl_core::AttributeSource). - pub fn with_attribute_tree(mut self, tree: Arc) -> Self { - self.attribute_tree = tree; - self - } - - /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs - /// to support multiple dialects (Cedar + OPA + NeMo) on the same - /// route — the router dispatches each `pdp(...)` step by dialect. - /// Pass a single resolver when only one dialect is in use; APL - /// steps for any other dialect will then return - /// `PdpError::NoResolver` at evaluation time. - pub fn with_pdp(mut self, pdp: Arc) -> Self { - self.pdp = pdp; - self - } - - /// Sugar for the common "register many resolvers" path. Builds a - /// [`PdpRouter`], registers each resolver into it, then installs the - /// router. Equivalent to constructing a `PdpRouter` by hand and - /// passing it to [`Self::with_pdp`]. - pub fn with_pdp_router( - mut self, - resolvers: impl IntoIterator>, - ) -> Self { - let mut router = PdpRouter::new(); - for r in resolvers { - router.register(r); - } - self.pdp = Arc::new(router); - self - } -} - -#[async_trait] -impl Plugin for AplRouteHandler { - fn config(&self) -> &PluginConfig { - &self.config - } -} - -#[async_trait] -impl AnyHookHandler for AplRouteHandler { - async fn invoke( - &self, - payload: &dyn PluginPayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - // Downcast to the CMF payload — this handler only registers for - // cmf.* hook names, so the executor should always hand us a - // MessagePayload. A mismatch indicates a framework wiring bug. - let msg_payload = payload - .as_any() - .downcast_ref::() - .ok_or_else(|| { - Box::new(PluginError::Config { - message: format!( - "AplRouteHandler '{}': payload was not MessagePayload", - self.route.route_key - ), - }) - })?; - - let manager = self.manager.upgrade().ok_or_else(|| { - Box::new(PluginError::Config { - message: format!( - "AplRouteHandler '{}': PluginManager dropped before invoke", - self.route.route_key - ), - }) - })?; - - // Build (or reuse) the dispatch plan for this route. Cache keyed - // by `(route_key, manager.config_generation())` — if the manager - // has reloaded since the last invoke, the next lookup rebuilds. - let plan = self - .dispatch_cache - .get_or_build(&self.route, &self.plugin_registry, &manager) - .await; - - // CmfPluginInvoker carries the request-scoped payload + extensions - // under interior mutability so successive plugin calls accumulate - // mutations. Hydration + persistence are no-ops when there's no - // session id (the common case for the first request in a session). - // Wrapped in Arc so it can be erased to `Arc` - // for the apl-core entry points (which take `&Arc` - // so `dispatch_parallel` can clone an owned, 'static reference into - // each spawned branch). Inherent-method calls on `CmfPluginInvoker` - // (e.g. `extensions_arc`, `persist_session`) deref through the Arc. - // Hydration loads accumulated session labels. A store failure - // here happens *before* any policy decision, so we fail the - // request closed immediately: deny with a - // distinguished violation rather than proceeding as if the - // session carried no taint. Sessionless traffic never reaches - // the store, so this only denies session-bearing requests. - let invoker = match CmfPluginInvoker::for_request( - Arc::clone(&manager), - extensions.clone(), - msg_payload.clone(), - plan, - Arc::clone(&self.session_store), - ) - .await - { - Ok(inv) => Arc::new(inv), - Err(e) => { - tracing::error!( - alarm = "session_store_failure", - op = "load", - route = %self.route.route_key, - error = %e, - "session label load failed; failing request closed" - ); - let mut v = PluginViolation::new( - "session.load_failed", - "session state could not be loaded", - ); - decorate_denial_response(&mut v, self.route.response.as_ref()); - return Ok(Box::new(ErasedResultFields { - continue_processing: false, - modified_payload: None, - modified_extensions: None, - violation: Some(v), - })); - }, - }; - - // Build the attribute bag. APL predicates read flat keys; the - // BagBuilder bridges typed CPEX extensions into that namespace. - // `route.key` lets default/policy-bundle predicates branch on - // which route they're attached to. - let post_extensions = invoker.current_extensions().await; - let mut bag = BagBuilder::new() - .with_extensions(&post_extensions) - .with_route_key(&self.route.route_key) - .with_data(&self.attribute_tree) - .build(); - - // Phase 5 retry seeding: if the agent echoed an elicitation id (from - // a prior `-32120`) in the `X-Policy-Elicitation-Id` header, seed it - // into the bag *before* evaluation. `dispatch_elicitation` then takes - // the "id present → check" path (poll the existing approval) instead - // of dispatching a fresh one. Without this, every retry would open a - // new approval and the loop would never resolve. - if let Some(elicitation_id) = elicitation_id_from_headers(&post_extensions) { - bag.set(apl_core::step::elicitation_bag_keys::ID, elicitation_id); - } - - // Build `RoutePayload.args` from the message. Per-content shape: - // * ToolCall → arguments map (JSON Object) - // * PromptRequest → arguments map (JSON Object) - // * Text-only → JSON String of concatenated text content - // - // Field pipelines operate on `args.` paths. Result starts - // as Null on Pre (no upstream response yet); the Post phase - // would extract from a ToolResult / PromptResult — deferred - // until result-side handling lands. - let args_value = extract_args_from_message(&msg_payload.message); - let mut route_payload = match self.phase { - Phase::Pre => RoutePayload::new(args_value), - Phase::Post => { - // Pull the upstream result out of the message so APL - // `result.` predicates and the `result:` - // pipeline have something to operate on. Falls back to - // `Value::Null` when the message has no ToolResult / - // PromptResult / Resource content (e.g. for hooks that - // fire on entities without a structured result). - let result_value = extract_result_from_message(&msg_payload.message); - RoutePayload::with_result(args_value, result_value) - }, - }; - - // Flatten the call args into the bag under `args.`. APL's - // own args pipelines read from `route_payload.args` directly, - // but PDP steps and predicates that reference `${args.X}` / - // `args.X` resolve through the bag. Mirroring the args here - // makes both consumers see the same vocabulary the - // `MessageView` exposes. (Bag-mutation via redact during the - // args pipeline isn't reflected back into the bag; that's fine - // — args predicates today read from `route_payload.args`, and - // the cedar substitution snapshots the pre-args view, which is - // what an author writing `cedar:(resource.id: ${args.X})` would - // expect.) - extract_args(&route_payload.args, &mut bag); - // Post phase: also project the upstream result into the bag - // under `result.`. This is what enables predicates like - // `redact(result.ssn) when !perm.view_ssn` and `require(...)` - // gates that branch on the result. Pre phases skip this — the - // result is `None` by construction. - if matches!(self.phase, Phase::Post) { - if let Some(result_value) = route_payload.result.as_ref() { - extract_result(result_value, &mut bag); - } - } - - // Real delegation invoker, sharing the CMF invoker's - // extensions Mutex so a `delegate(...)` step's writes to - // raw_credentials / delegation are visible to downstream CMF - // plugins and to the post phase. Routes that don't declare - // any `Step::Delegate` won't have entries in the plan's - // `delegation_entries` map; if such a route accidentally hits - // `delegate(...)`, the invoker returns `NotFound` and the - // evaluator translates it via the step's `on_error`. - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&manager), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - // Unsized coercion: `Arc` → `Arc`. The - // erased forms get borrowed into `evaluate_pre`/`evaluate_post`; - // `dispatch_parallel` can then `Arc::clone` an owned 'static - // reference into each branch closure. - // Elicitation bridge — resolves `require_approval(...)` / - // `confirm(...)` steps to `ElicitationHook` plugins by name off - // the same plan, sharing the request's Extensions so the handler - // reads the same identity. Routes with no elicitation steps have - // an empty `elicitation_entries` map; an accidental `Effect::Elicit` - // then returns `NotFound`, handled by the step's `on_error`. - let elicitations = Arc::new(ElicitationPluginInvoker::new( - Arc::clone(&manager), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let invoker_dyn: Arc = invoker.clone(); - let delegations_dyn: Arc = delegations.clone(); - let elicitations_dyn: Arc = elicitations.clone(); - - let decision = match self.phase { - Phase::Pre => { - evaluate_pre( - &self.route, - &mut bag, - &mut route_payload, - &self.pdp, - &invoker_dyn, - &delegations_dyn, - &elicitations_dyn, - ) - .await - }, - Phase::Post => { - evaluate_post( - &self.route, - &mut bag, - &mut route_payload, - &self.pdp, - &invoker_dyn, - &delegations_dyn, - &elicitations_dyn, - ) - .await - }, - }; - - // Drain Session-scoped taints (from `taint(label, session)` / - // pipeline `Stage::Taint`) into `extensions.security.labels` - // so the existing label-diff flow inside `persist_session` - // picks them up. Message-scoped taints are filtered out by - // `apply_session_taints` — they need their own destination. - // No-op when no taints emitted. - invoker.apply_session_taints(&decision.taints).await; - - // Fold this request's `restrict` constraints into one typed - // `CandidateConstraintExtension`. A custom-label contradiction - // (two restricts requiring the same label to differ) cannot be - // honored by any backend, so it fails closed below (mirrors the - // persist-failure handling). `Ok(None)` = no restrict fired. - let (folded_constraint, constraint_conflict) = - match fold_candidate_constraints(&decision.constraints) { - Ok(folded) => (folded, None), - Err(e) => (None, Some(e)), - }; - - // Commit any session-scoped labels accumulated during this - // request. No-op when there was no session id. The result is - // folded into the decision below — captured here because - // `continue_processing`/`violation` are computed after persist. - let persist_result = invoker.persist_session().await; - - // Surface the final mutated payload + extensions back into the - // PipelineResult the executor returns to the host. The host's - // body re-serialization picks up edits made by APL pipelines - // (e.g. a redact stage that rewrote args.text). - let final_payload = invoker.current_payload().await; - let final_extensions = invoker.current_extensions().await; - - // The pre-evaluation projections. No longer used to *detect* - // pipeline edits (the decision reports those) — they're the - // baseline for folding those edits back in below, which needs to - // know which paths the pipeline touched. - // - // Each side is projected only in the phase that can edit it: - // `evaluate_pre` never sets `result_modified` and `evaluate_post` - // never sets `args_modified`, so the other projection would be - // unread work on every request. - let pre_args = match self.phase { - Phase::Pre => Some(extract_args_from_message(&msg_payload.message)), - Phase::Post => None, - }; - let pre_result = match self.phase { - Phase::Pre => None, - Phase::Post => Some(extract_result_from_message(&msg_payload.message)), - }; - // Which of the three sources changed the payload, in precedence - // order. Each condition is a signal from the code that performed - // the change: the decision's flags are set when a pipeline's - // `set_dotted` / `remove_dotted` actually writes, and the - // invoker's flag is set when a plugin's payload is accepted. - // Nothing here infers a change by comparing values. - let modified_payload: Option> = if decision.args_modified { - // An args pipeline (Pre) rewrote a field. Fold the new - // args back into a fresh MessagePayload so downstream - // readers (the host's body re-serializer) see the - // change. - // - // Only the paths the pipeline touched are applied. A plugin - // may have rewritten other arguments on the same tool call, - // and those edits aren't in `route_payload.args` (it was - // projected before any plugin ran), so writing it wholesale - // would silently drop them. - // - // Without a pre-projection there is no way to tell which - // paths the pipeline changed, so write nothing rather than - // fold in an unattributable diff — a wholesale write is - // exactly the clobbering this merge exists to prevent. Only - // the Pre phase sets `args_modified`, and only the Pre phase - // projects `pre_args`, so this holds by construction. - let mut updated = final_payload.clone(); - if let Some(pre) = pre_args.as_ref() { - let mut merged = extract_args_from_message(&updated.message); - apply_changed_paths(&mut merged, pre, &route_payload.args); - write_args_back_to_message(&mut updated.message, &merged); - } - Some(Box::new(updated) as Box) - } else if decision.result_modified { - // A `result:` pipeline rewrote a field in the upstream - // response. Fold the new result back into the message - // so the host's response body re-serializer can write - // it out before forwarding downstream. Only the Post phase - // can set this — a Pre route has no result to rewrite. - // - // Same per-path merge as the args branch above, for the same - // reason: a plugin may have redacted a different part of the - // same tool result. - // Same "no pre-projection, no write" rule as the args branch - // above, for the same reason. - let mut updated = final_payload.clone(); - if let (Some(result_value), Some(pre)) = - (route_payload.result.as_ref(), pre_result.as_ref()) - { - let mut merged = extract_result_from_message(&updated.message); - apply_changed_paths(&mut merged, pre, result_value); - write_result_back_to_message(&mut updated.message, &merged); - } - Some(Box::new(updated) as Box) - } else if invoker.payload_was_modified() { - // A plugin mutated the message directly via `modify_payload` - // (not through a field pipeline). Pass the invoker's view - // through unchanged. - // - // The invoker records this when it accepts the mutation, - // which is the only point it can be known. Comparing message - // content here instead would read text parts only, so a - // redacted tool result, a rewritten tool call, or an edited - // thinking block would look identical to no mutation and get - // dropped. - tracing::debug!( - route = %self.route.route_key, - "plugin mutated the payload directly; forwarding the mutated view" - ); - Some(Box::new(final_payload) as Box) - } else { - None - }; - - let mut modified_extensions = if extensions_changed(extensions, &final_extensions) { - Some(final_extensions.cow_copy()) - } else { - None - }; - - // Write the folded constraint into the typed - // `candidate_constraint` extension slot so the host router reads - // it TYPED off `PipelineResult.modified_extensions` — the same - // in-process, type-shared channel `raw_credentials.delegated_tokens` - // rides. `extensions_changed` doesn't track this - // slot, so we force `modified_extensions` to `Some` here to - // guarantee the constraint reaches the executor's merge. - if let Some(constraint) = folded_constraint { - let mut owned = modified_extensions - .take() - .unwrap_or_else(|| final_extensions.cow_copy()); - owned.candidate_constraint = Some(constraint); - modified_extensions = Some(owned); - } - - // A suspended phase reports `Allow` with a pending bundle — it - // must NOT forward. Fail closed with a distinguished violation that - // carries the elicitation id (mapped to JSON-RPC `-32120`) so the - // suspend is visible and the unapproved call never proceeds. - let pending_elicitation = decision.pending.clone(); - - // Attach the route's transpiled `denyWith` to a violation at each - // genuine-denial site (below) via `decorate_denial_response`, rather - // than blanket-decorating whatever `violation` is set. This keeps the - // custom response off any future non-denial signal (e.g. an - // elicitation/retry/confirm violation) that must reach the host with - // its own wire shape intact. - let (mut continue_processing, mut violation) = match decision.decision { - Decision::Allow => (true, None), - Decision::Deny { - reason, - rule_source, - } => { - let code = if rule_source.is_empty() { - "policy.deny".to_string() - } else { - rule_source - }; - let reason = reason.unwrap_or_else(|| "access denied".to_string()); - let mut v = PluginViolation::new(code, reason); - decorate_denial_response(&mut v, self.route.response.as_ref()); - (false, Some(v)) - }, - }; - - if let Some(p) = &pending_elicitation { - tracing::info!( - route = %self.route.route_key, - elicitation_id = %p.id, - plugin = %p.plugin_name, - "policy suspended on pending elicitation; emitting -32120 (retry)" - ); - // The phase suspended awaiting a human. Do NOT forward. Surface - // a structured "request not complete — retry echoing this id" - // via the protocol error code the host maps to the wire - // (JSON-RPC `-32120`). Left undecorated by `denyWith` — it is a - // retry signal, not a denial. - continue_processing = false; - violation = Some(pending_violation(p)); - } - - // Peek (confirm-then-apply): the agent re-checked an approval but - // asked NOT to commit yet (the `X-Policy-Elicitation-Peek` header). If - // the elicitation resolved approved (Allow, not pending), report - // "approved — confirm to apply" (-32121) and do NOT forward. The - // agent then asks the requester, who re-sends without the peek header - // to actually run the tool (the plugin replays the cached approval). - if continue_processing - && elicitation_peek_from_headers(&post_extensions) - && bag.get_string(apl_core::step::elicitation_bag_keys::OUTCOME) == Some("approved") - { - continue_processing = false; - violation = Some(approved_peek_violation(&bag)); - } - - // Append fail-closed with merge precedence: - // - decision Allow + append Err → flip to Deny with a - // distinguished `session.persist_failed` violation. - // - decision Deny + append Err → keep the original policy - // violation (preserve attribution); the request is already - // denied. The append failure surfaces only as the alarm. - // The alarm/metric fires on every append failure regardless of - // decision, since the dangerous residual is a *selective* - // failure (append rejected while reads still succeed). - if let Err(e) = persist_result { - tracing::error!( - alarm = "session_store_failure", - op = "append", - route = %self.route.route_key, - decision_was_allow = continue_processing, - error = %e, - "session label persist failed; failing request closed" - ); - if continue_processing { - continue_processing = false; - let mut v = PluginViolation::new( - "session.persist_failed", - "session state could not be persisted", - ); - decorate_denial_response(&mut v, self.route.response.as_ref()); - violation = Some(v); - } - } - - // Fail closed: a `restrict` custom-label contradiction means no - // backend can satisfy the request's routing constraints. Deny - // rather than emit an unhonorable constraint. On an already-denied - // request, keep the original policy attribution (same precedence - // as the persist-failure block above). - if let Some(conflict) = constraint_conflict { - tracing::warn!( - route = %self.route.route_key, - error = %conflict, - "restrict constraints conflict; failing request closed" - ); - if continue_processing { - continue_processing = false; - violation = Some(PluginViolation::new( - "policy.restrict_conflict", - conflict.to_string(), - )); - } - } - - Ok(Box::new(ErasedResultFields { - continue_processing, - modified_payload, - modified_extensions, - violation, - })) - } - - fn hook_type_name(&self) -> &'static str { - // CmfHook::NAME — kept as a literal here to avoid pulling in the - // HookTypeDef trait just for the constant. - "cmf" - } -} - -/// Attach a route's transpiled `denyWith` (status/body/headers) to a -/// denial `violation`'s `details` map so the host can render a custom HTTP -/// denial response. Carried via `details` (not new violation fields) to -/// keep the violation type stable. `None` response leaves the host default. -/// -/// Call this only from genuine-denial sites — never blanket-apply it to -/// whatever violation happens to be set, or a non-denial signal (e.g. an -/// elicitation/retry/confirm) would get stamped with a `403`-shaped -/// response the host would render instead of the intended wire signal. -fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) { - let Some(resp) = response else { - return; - }; - if let Some(status) = resp.status { - violation - .details - .insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status)); - } - if let Some(body) = &resp.body { - violation - .details - .insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body)); - } - if !resp.headers.is_empty() { - violation.details.insert( - DETAIL_HTTP_HEADERS.to_string(), - serde_json::json!(resp.headers), - ); - } -} - -/// Cheap pointer-equality check across the few mutable extension slots -/// the executor would care about. False positives (claiming a change -/// when there isn't one) are cheap — the executor re-validates anyway. -fn extensions_changed(before: &Extensions, after: &Extensions) -> bool { - let security_changed = match (before.security.as_ref(), after.security.as_ref()) { - (Some(a), Some(b)) => !Arc::ptr_eq(a, b), - (None, None) => false, - _ => true, - }; - let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) { - (Some(a), Some(b)) => !Arc::ptr_eq(a, b), - (None, None) => false, - _ => true, - }; - // `delegate(...)` steps write minted tokens into - // `raw_credentials.delegated_tokens` via the shared Mutex — - // without this check, a route whose only Extensions mutation is - // a delegate (no security / delegation chain edit) looks - // unchanged, so the executor never merges the minted token back - // and downstream readers (our HttpFilter attaching the token to - // the upstream request) see nothing. - let raw_creds_changed = match ( - before.raw_credentials.as_ref(), - after.raw_credentials.as_ref(), - ) { - (Some(a), Some(b)) => !Arc::ptr_eq(a, b), - (None, None) => false, - _ => true, - }; - security_changed || delegation_changed || raw_creds_changed -} - -/// Extract the elicitation id an agent echoes on retry from the -/// `X-Policy-Elicitation-Id` request header. `None` when absent/empty. -/// Pure so it's unit-testable without the full handler path. -fn elicitation_id_from_headers(ext: &Extensions) -> Option { - ext.http - .as_ref() - .and_then(|h| h.get_request_header(ELICITATION_ID_HEADER)) - .filter(|v| !v.is_empty()) - .map(str::to_string) -} - -/// True when the agent set `X-Policy-Elicitation-Peek` to a truthy value — -/// it wants to resolve the approval's status without committing the action. -fn elicitation_peek_from_headers(ext: &Extensions) -> bool { - ext.http - .as_ref() - .and_then(|h| h.get_request_header(ELICITATION_PEEK_HEADER)) - .is_some_and(|v| !v.is_empty() && !v.eq_ignore_ascii_case("false") && v != "0") -} - -/// Build the `-32121` "approved — confirm to apply" violation for a peek -/// that resolved approved. Carries the elicitation id + approver in -/// `details` so the agent can ask the requester and then re-send (without -/// the peek header) to actually run the tool. -fn approved_peek_violation(bag: &apl_core::attributes::AttributeBag) -> PluginViolation { - use apl_core::step::elicitation_bag_keys as bk; - let mut details: std::collections::HashMap = std::collections::HashMap::new(); - if let Some(id) = bag.get_string(bk::ID) { - details.insert("elicitation_id".into(), Value::String(id.to_string())); - } - if let Some(approver) = bag.get_string(bk::APPROVER) { - details.insert("approver".into(), Value::String(approver.to_string())); - } - PluginViolation::new( - "elicitation.approved", - "approved — confirm to apply (re-send without the peek header)".to_string(), - ) - .with_proto_error_code(ELICITATION_APPROVED_CODE) - .with_details(details) -} - -/// Build the `-32120` violation for a suspended phase: a distinguished -/// code, the protocol error code the host maps to the wire, and the -/// elicitation bundle in `details` so the agent can show who's approving / -/// when it expires and retry by re-sending the id. -fn pending_violation(p: &apl_core::step::PendingElicitation) -> PluginViolation { - let mut details: std::collections::HashMap = std::collections::HashMap::new(); - details.insert("elicitation_id".into(), Value::String(p.id.clone())); - details.insert("plugin".into(), Value::String(p.plugin_name.clone())); - for (key, val) in [ - ("approver", &p.approver), - ("channel", &p.channel), - ("expires_at", &p.expires_at), - ("intent_id", &p.intent_id), - ] { - if let Some(v) = val { - details.insert(key.into(), Value::String(v.clone())); - } - } - PluginViolation::new( - "elicitation.pending", - format!( - "awaiting approval `{}` via `{}` — retry with this id", - p.id, p.plugin_name - ), - ) - .with_proto_error_code(ELICITATION_PENDING_CODE) - .with_details(details) -} - -#[cfg(test)] -mod phase5_tests { - use super::*; - use cpex_core::extensions::HttpExtension; - use std::sync::Arc; - - fn pending(id: &str) -> apl_core::step::PendingElicitation { - apl_core::step::PendingElicitation { - id: id.to_string(), - plugin_name: "manager-approver".to_string(), - approver: Some("alice".to_string()), - intent_id: None, - channel: Some("ciba".to_string()), - expires_at: Some("2026-12-31T00:00:00Z".to_string()), - source: "route.payroll.policy[0]".to_string(), - } - } - - #[test] - fn pending_violation_carries_minus32120_and_bundle() { - let v = pending_violation(&pending("elic-1")); - assert_eq!(v.proto_error_code, Some(ELICITATION_PENDING_CODE)); - assert_eq!(v.code, "elicitation.pending"); - assert_eq!(v.details.get("elicitation_id").unwrap(), "elic-1"); - assert_eq!(v.details.get("approver").unwrap(), "alice"); - assert_eq!(v.details.get("channel").unwrap(), "ciba"); - assert_eq!(v.details.get("expires_at").unwrap(), "2026-12-31T00:00:00Z"); - // Absent optional → not in details. - assert!(!v.details.contains_key("intent_id")); - } - - #[test] - fn elicitation_id_extracted_from_header_case_insensitively() { - let mut http = HttpExtension::default(); - http.set_request_header("x-policy-elicitation-id", "elic-42"); - let ext = Extensions { - http: Some(Arc::new(http)), - ..Extensions::default() - }; - assert_eq!( - elicitation_id_from_headers(&ext).as_deref(), - Some("elic-42") - ); - } - - #[test] - fn no_header_yields_none() { - // No http extension at all. - assert!(elicitation_id_from_headers(&Extensions::default()).is_none()); - // Header present but empty → treated as absent. - let mut http = HttpExtension::default(); - http.set_request_header(ELICITATION_ID_HEADER, ""); - let ext = Extensions { - http: Some(Arc::new(http)), - ..Extensions::default() - }; - assert!(elicitation_id_from_headers(&ext).is_none()); - } -} diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs deleted file mode 100644 index a72a8c0c..00000000 --- a/crates/apl-cpex/src/session_resolver.rs +++ /dev/null @@ -1,669 +0,0 @@ -// Location: ./crates/apl-cpex/src/session_resolver.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// 3-tier session-id resolver. The Python apl-plugins `SessionResolver` -// (cpex/framework/session.py) shipped a 4-tier version including a -// client-supplied `X-CPEX-Session-Id` header tier. **That tier is -// excluded by design here**: an authenticated client can set the -// header to another subject's known session id and inherit their -// accumulated taint labels, or to a new value and escape their own -// tainted session — defeating `session.labels`-based deny policies -// entirely. The Python comment framed the header as a feature ("lets -// a smart client maintain its own session boundary"); under threat -// modeling it is a privilege-escalation channel with no surviving -// use case the other tiers don't cover. If a future deployment needs -// client-supplied session grouping, the right shape is a subject- -// bound hash (`sha256(subject_id : client_value)`), not the raw -// header value. -// -// The resolver walks these tiers in order, returning the first hit: -// -// 0. `agent` — `AgentExtension.session_id`. A *pre-resolved* -// value: an upstream plugin or middleware decided what the -// session is and wrote it here (for the FFI/AuthBridge path this -// is the client `X-Session-Id` header / A2A contextId). Highest -// priority among sources, but **subject-bound** before use -// (`sha256(subject_id : value)`): the raw value is attacker-chosen, -// so it must only scope state WITHIN the authenticated subject, -// never across principals. Falls through when no subject is present. -// -// 1. `token_claim` — explicit `session_id` claim in the inbound JWT. -// Strongest binding among the *derived* tiers: the auth issuer -// chose this session and signed it into the token. Read from -// `SecurityExtension.subject.claims["session_id"]` and **subject- -// bound** the same way (a signed claim is per-issuer and may repeat -// across principals, so the key must still include the subject). -// -// 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16]. -// No special infrastructure needed; the triple is already populated -// by `cpex-plugin-identity-jwt`'s claim mapping. Same user + same agent + -// same gateway = same session, stable across token refresh (the -// claims are stable even when the token string isn't). -// -// 3. `none` — no usable identifier; caller (CmfPluginInvoker) -// skips hydration / persistence. Returns `Ok(None)` so the caller -// can distinguish "no session" from "resolver error" if we ever -// add an error variant. -// -// Each tier reads from a typed `Extensions` field, not raw JWT/HTTP -// payloads — those have already been mapped by upstream identity -// plugins (cpex-plugin-identity-jwt). The resolver stays free of crypto / -// parsing logic. - -use cpex_core::extensions::Extensions; -use sha2::{Digest, Sha256}; - -/// Which tier produced the session id. Useful for diagnostics / audit -/// and to let downstream code branch on binding strength (e.g., only -/// trust `token_claim`-derived sessions for the highest-stakes -/// operations). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionSource { - /// Pre-resolved by an upstream plugin via `AgentExtension.session_id`. - /// Highest priority — represents an authoritative decision. - Agent, - /// JWT `session_id` claim — strongest binding among derived tiers. - TokenClaim, - /// Derived from the identity triple. Stable across token refresh. - Identity, -} - -impl SessionSource { - pub fn as_str(self) -> &'static str { - match self { - SessionSource::Agent => "agent", - SessionSource::TokenClaim => "token_claim", - SessionSource::Identity => "identity", - } - } -} - -/// 16 hex chars (64 bits) of `sha256(raw)`. Shared by the identity tier -/// and the subject-binding of the Agent/TokenClaim tiers so all derived -/// session ids have one keying scheme. Matches the Python implementation's -/// `hexdigest()[:16]`. -fn short_hash(raw: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(raw.as_bytes()); - let digest = hasher.finalize(); - digest - .iter() - .take(8) - .map(|b| format!("{:02x}", b)) - .collect() -} - -/// Bind a client/upstream-supplied raw session value to the authenticated -/// subject: `sha256(subject_id : raw)`. This is the subject-bound shape the -/// module doc prescribes for the (previously raw) Agent and TokenClaim tiers, -/// so a session id chosen by one principal cannot address another principal's -/// session bucket. Returns `None` when there is no authenticated subject — a -/// bare client value has no safe scope, consistent with the identity tier, -/// which also requires a subject. -fn subject_scoped(subject_id: Option<&str>, raw: &str) -> Option { - let sub = subject_id?; - Some(short_hash(&format!("{}:{}", sub, raw))) -} - -/// Resolve a session id from the request's `Extensions`. Returns -/// `Some((id, source))` on the first tier that hits, or `None` when -/// every tier comes up empty (anonymous request, no claims, no -/// header, no identity). -/// -/// Identity-tier (2) requires at minimum `security.subject.id` to be -/// populated — without an end-user identifier there's no meaningful -/// session boundary to hash against. The other two identity-triple -/// components (caller_workload, this_workload) fall back to the -/// `"-"` sentinel when absent, which keeps the hash defined but -/// degrades to a (sub, *, *) session — usually fine for demos with -/// a single gateway and single agent. -pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> { - // The authenticated subject, populated by the identity resolvers - // (cpex-plugin-identity-jwt) before this runs. Every client/upstream-supplied - // session value below is bound to it so one principal can't address - // another's session bucket. - let subject_id = ext - .security - .as_deref() - .and_then(|s| s.subject.as_ref()) - .and_then(|s| s.id.as_deref()); - - // Tier 0: pre-resolved by an upstream plugin (for the FFI/AuthBridge - // path this is `X-Session-Id` / the A2A contextId). Authoritative among - // sources, but subject-bound here rather than trusted raw: the raw value - // is attacker-chosen, so it only ever scopes state WITHIN the - // authenticated subject. Falls through when no subject is present. - if let Some(agent) = ext.agent.as_deref() { - if let Some(sid) = agent.session_id.as_deref() { - if !sid.is_empty() { - if let Some(bound) = subject_scoped(subject_id, sid) { - return Some((bound, SessionSource::Agent)); - } - } - } - } - - // Tier 1: explicit JWT `session_id` claim — also subject-bound. Even a - // signed claim is per-issuer and could repeat across principals, so the - // store key must still incorporate the subject. - if let Some(sec) = ext.security.as_deref() { - if let Some(subj) = sec.subject.as_ref() { - if let Some(sid) = subj.claims.get("session_id") { - if !sid.is_empty() { - if let Some(bound) = subject_scoped(subject_id, sid) { - return Some((bound, SessionSource::TokenClaim)); - } - } - } - } - } - - // Tier 2: identity-derived. Hash the triple - // (end-user : calling agent : our gateway) — stable across token - // refresh because all three components survive token rotation. - if let Some(sec) = ext.security.as_deref() { - let sub = sec.subject.as_ref().and_then(|s| s.id.as_deref()); - if let Some(sub) = sub { - // Fall back to `-` so a missing component degrades the - // session to (sub, *, *) rather than the resolver silently - // returning None. Important for demos where the gateway - // hasn't yet attested its own `this_workload` identity. - let actor = sec - .caller_workload - .as_ref() - .and_then(|w| w.client_id.as_deref()) - .unwrap_or("-"); - let aud = sec - .this_workload - .as_ref() - .and_then(|w| w.client_id.as_deref()) - .unwrap_or("-"); - let raw = format!("{}:{}:{}", sub, actor, aud); - return Some((short_hash(&raw), SessionSource::Identity)); - } - } - - // Tier 3: no session. - None -} - -#[cfg(test)] -mod tests { - use super::*; - use cpex_core::extensions::{ - AgentExtension, Extensions, HttpExtension, SecurityExtension, SubjectExtension, - WorkloadIdentity, - }; - use std::sync::Arc; - - fn extensions_with_security(sec: SecurityExtension) -> Extensions { - Extensions { - security: Some(Arc::new(sec)), - ..Default::default() - } - } - - fn subject_with_claims(id: Option<&str>, claims: &[(&str, &str)]) -> SubjectExtension { - SubjectExtension { - id: id.map(String::from), - claims: claims - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(), - ..Default::default() - } - } - - // Build Extensions carrying both an agent.session_id and a subject id. - fn extensions_with_agent_and_subject(session_id: &str, subject_id: &str) -> Extensions { - let mut agent = AgentExtension::default(); - agent.session_id = Some(session_id.into()); - Extensions { - agent: Some(Arc::new(agent)), - security: Some(Arc::new(SecurityExtension { - subject: Some(subject_with_claims(Some(subject_id), &[])), - ..Default::default() - })), - ..Default::default() - } - } - - #[test] - fn tier0_agent_session_id_is_subject_bound() { - // A pre-resolved (client-supplied) session id is hashed together - // with the authenticated subject, never returned raw. - let ext = extensions_with_agent_and_subject("sess-upstream", "alice"); - let (sid, src) = resolve_session(&ext).expect("should resolve"); - assert_eq!(src, SessionSource::Agent); - assert_eq!(sid, subject_scoped(Some("alice"), "sess-upstream").unwrap()); - assert_ne!(sid, "sess-upstream", "raw client value must not be the key"); - } - - #[test] - fn tier0_same_session_id_different_subjects_are_distinct() { - // Guarantee: principal A reusing principal B's - // session id must NOT land in B's session bucket. - let alice = extensions_with_agent_and_subject("shared-sid", "alice"); - let bob = extensions_with_agent_and_subject("shared-sid", "bob"); - let (sid_a, _) = resolve_session(&alice).unwrap(); - let (sid_b, _) = resolve_session(&bob).unwrap(); - assert_ne!( - sid_a, sid_b, - "same client session id under different subjects must not collide", - ); - } - - #[test] - fn tier0_stable_for_same_subject_and_session_id() { - // Same subject + same client session id → same key, so a legit - // user's taint persists across their own request/response cycles. - let (sid1, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap(); - let (sid2, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap(); - assert_eq!(sid1, sid2); - } - - #[test] - fn tier0_no_subject_falls_through() { - // A client session id with no authenticated subject has no safe - // scope: do not honor it (no anonymous cross-readable bucket). - let mut agent = AgentExtension::default(); - agent.session_id = Some("sess-upstream".into()); - let ext = Extensions { - agent: Some(Arc::new(agent)), - ..Default::default() - }; - assert!(resolve_session(&ext).is_none()); - } - - #[test] - fn tier0_skips_empty_agent_session_id() { - // Empty agent.session_id should fall through, otherwise an - // upstream that accidentally cleared the slot aliases every - // such request to "". - let mut agent = AgentExtension::default(); - agent.session_id = Some("".into()); - let ext = Extensions { - agent: Some(Arc::new(agent)), - security: Some(Arc::new(SecurityExtension { - subject: Some(subject_with_claims(Some("alice"), &[])), - ..Default::default() - })), - ..Default::default() - }; - // Empty Tier 0 falls through; identity tier (subject present) hits. - let (_, src) = resolve_session(&ext).expect("should fall through to identity"); - assert_eq!(src, SessionSource::Identity); - } - - #[test] - fn tier0_wins_over_token_claim() { - // Pre-resolved value beats a JWT claim — upstream authority — and is - // subject-bound rather than returned raw. - let mut agent = AgentExtension::default(); - agent.session_id = Some("from-agent".into()); - let sec = SecurityExtension { - subject: Some(subject_with_claims( - Some("alice"), - &[("session_id", "from-token")], - )), - ..Default::default() - }; - let ext = Extensions { - agent: Some(Arc::new(agent)), - security: Some(Arc::new(sec)), - ..Default::default() - }; - - let (sid, src) = resolve_session(&ext).unwrap(); - assert_eq!(src, SessionSource::Agent); - assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap()); - } - - #[test] - fn tier0_wins_over_identity() { - // The agent tier (`agent.session_id`) must win over the identity - // tier (identity triple) when both are available. Pins the walk - // order explicitly so a future refactor regresses loudly. - let mut agent = AgentExtension::default(); - agent.session_id = Some("from-agent".into()); - let sec = SecurityExtension { - subject: Some(subject_with_claims(Some("alice"), &[])), - caller_workload: Some(WorkloadIdentity { - client_id: Some("agent-007".into()), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - client_id: Some("praxis-gateway".into()), - ..Default::default() - }), - ..Default::default() - }; - let ext = Extensions { - agent: Some(Arc::new(agent)), - security: Some(Arc::new(sec)), - ..Default::default() - }; - - let (sid, src) = resolve_session(&ext).unwrap(); - assert_eq!( - src, - SessionSource::Agent, - "agent tier must win over identity tier when both are available", - ); - assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap()); - } - - #[test] - fn tier1_token_claim_hits_when_session_id_claim_present() { - let sec = SecurityExtension { - subject: Some(subject_with_claims( - Some("alice@corp.com"), - &[("session_id", "sess-from-token-789")], - )), - ..Default::default() - }; - let ext = extensions_with_security(sec); - - let (sid, src) = resolve_session(&ext).expect("should resolve"); - assert_eq!(src, SessionSource::TokenClaim); - // Subject-bound, not the raw claim value. - assert_eq!( - sid, - subject_scoped(Some("alice@corp.com"), "sess-from-token-789").unwrap() - ); - assert_ne!(sid, "sess-from-token-789"); - } - - #[test] - fn tier1_skips_empty_session_id_claim() { - // Empty claim values should NOT win tier 1 — they degrade to - // identity-derived. Otherwise an issuer accidentally putting - // an empty string in the claim would yield "" as the session - // key, which would alias every such request. - let sec = SecurityExtension { - subject: Some(subject_with_claims(Some("alice"), &[("session_id", "")])), - ..Default::default() - }; - let ext = extensions_with_security(sec); - - let (_, src) = resolve_session(&ext).expect("should fall through to identity"); - assert_eq!(src, SessionSource::Identity); - } - - #[test] - fn tier1_same_session_id_claim_different_subjects_are_distinct() { - // The cross-principal guarantee for the token-claim tier. An - // issuer that reuses a session_id value across multiple principals - // (multi-tenant naming conventions, counters that don't carry the - // subject, etc.) must NOT let one principal land in another's - // session bucket. Direct mirror of the agent-tier test above. - let mk = |sub: &str| -> SecurityExtension { - SecurityExtension { - subject: Some(subject_with_claims( - Some(sub), - &[("session_id", "issuer-shared-sid")], - )), - ..Default::default() - } - }; - let (sid_a, _) = resolve_session(&extensions_with_security(mk("alice"))).unwrap(); - let (sid_b, _) = resolve_session(&extensions_with_security(mk("bob"))).unwrap(); - assert_ne!( - sid_a, sid_b, - "same JWT session_id claim under different subjects must not collide", - ); - } - - #[test] - fn tier1_stable_for_same_subject_and_session_id_claim() { - // Same subject + same claim value → same key. A legit user's - // session stays consistent across requests carrying the same - // claim, so accumulated taint persists where it should. - let mk = || -> Extensions { - extensions_with_security(SecurityExtension { - subject: Some(subject_with_claims( - Some("alice"), - &[("session_id", "claim-value-42")], - )), - ..Default::default() - }) - }; - let (sid1, _) = resolve_session(&mk()).unwrap(); - let (sid2, _) = resolve_session(&mk()).unwrap(); - assert_eq!(sid1, sid2); - } - - #[test] - fn tier1_no_subject_id_falls_through() { - // A JWT carries a `session_id` claim but has no `sub` (subject - // present but `id == None`). The token-claim tier has no safe - // scope without a subject — must fall through. The identity tier - // also requires a subject, so resolution returns None overall. - let sec = SecurityExtension { - subject: Some(SubjectExtension { - id: None, - claims: [("session_id".to_string(), "claim-value".to_string())] - .into_iter() - .collect(), - ..Default::default() - }), - ..Default::default() - }; - let ext = extensions_with_security(sec); - assert!( - resolve_session(&ext).is_none(), - "claim with no subject id has no safe scope; must not honor", - ); - } - - #[test] - fn tier1_wins_over_identity() { - // Both a JWT session_id claim AND a full identity triple are - // present. The token-claim tier must win over the identity tier. - // Pins the priority explicitly — the happy-path test above omits - // identity-tier inputs, so otherwise the ordering is only implicit. - let sec = SecurityExtension { - subject: Some(subject_with_claims( - Some("alice"), - &[("session_id", "from-claim")], - )), - caller_workload: Some(WorkloadIdentity { - client_id: Some("agent-007".into()), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - client_id: Some("praxis-gateway".into()), - ..Default::default() - }), - ..Default::default() - }; - let ext = extensions_with_security(sec); - - let (sid, src) = resolve_session(&ext).unwrap(); - assert_eq!( - src, - SessionSource::TokenClaim, - "token-claim tier must win over identity tier when both are available", - ); - assert_eq!(sid, subject_scoped(Some("alice"), "from-claim").unwrap()); - } - - // The client-supplied `X-CPEX-Session-Id` header tier is - // intentionally absent — it has no slot in the walk above. - // - // The Python `SessionResolver` included a header tier; cpex Rust - // does not. See the module-level doc comment for the threat model. - // A spoofing-regression guard lives below in - // `header_x_cpex_session_id_is_ignored`. - - #[test] - fn tier2_identity_derived_when_no_claim() { - let sec = SecurityExtension { - subject: Some(subject_with_claims(Some("alice@corp.com"), &[])), - caller_workload: Some(WorkloadIdentity { - client_id: Some("agent-007".into()), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - client_id: Some("praxis-gateway".into()), - ..Default::default() - }), - ..Default::default() - }; - let ext = extensions_with_security(sec); - - let (sid, src) = resolve_session(&ext).expect("should resolve"); - assert_eq!(src, SessionSource::Identity); - // 16 hex chars (matches Python `sha256(...)[:16]`). - assert_eq!(sid.len(), 16); - assert!(sid.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn tier2_identity_is_stable_across_calls() { - // Same triple → same session id. Property guarantees that - // a token refresh (which doesn't change sub/caller/this) keeps - // the session intact. - let mk = || -> SecurityExtension { - SecurityExtension { - subject: Some(subject_with_claims(Some("alice@corp.com"), &[])), - caller_workload: Some(WorkloadIdentity { - client_id: Some("agent-007".into()), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - client_id: Some("praxis-gateway".into()), - ..Default::default() - }), - ..Default::default() - } - }; - let ext1 = extensions_with_security(mk()); - let ext2 = extensions_with_security(mk()); - let (sid1, _) = resolve_session(&ext1).unwrap(); - let (sid2, _) = resolve_session(&ext2).unwrap(); - assert_eq!(sid1, sid2); - } - - #[test] - fn tier2_distinguishes_different_users() { - let alice = SecurityExtension { - subject: Some(subject_with_claims(Some("alice"), &[])), - ..Default::default() - }; - let bob = SecurityExtension { - subject: Some(subject_with_claims(Some("bob"), &[])), - ..Default::default() - }; - let (sid_a, _) = resolve_session(&extensions_with_security(alice)).unwrap(); - let (sid_b, _) = resolve_session(&extensions_with_security(bob)).unwrap(); - assert_ne!(sid_a, sid_b); - } - - #[test] - fn tier2_distinguishes_different_agents() { - // Same user, two different agents → different sessions. - // Important so a malicious agent's accumulated taints don't - // affect a different agent that user runs. - let mk = |agent: &str| -> SecurityExtension { - SecurityExtension { - subject: Some(subject_with_claims(Some("alice"), &[])), - caller_workload: Some(WorkloadIdentity { - client_id: Some(agent.into()), - ..Default::default() - }), - ..Default::default() - } - }; - let (sid1, _) = resolve_session(&extensions_with_security(mk("agent-a"))).unwrap(); - let (sid2, _) = resolve_session(&extensions_with_security(mk("agent-b"))).unwrap(); - assert_ne!(sid1, sid2); - } - - #[test] - fn tier3_no_session_when_no_data() { - let ext = Extensions::default(); - assert!(resolve_session(&ext).is_none()); - } - - #[test] - fn tier3_no_session_when_no_subject_id() { - // Security exists but no subject id → identity can't hash. - // Claim is absent too. Should be None. - let sec = SecurityExtension { - subject: Some(SubjectExtension::default()), // id = None - ..Default::default() - }; - let ext = extensions_with_security(sec); - assert!(resolve_session(&ext).is_none()); - } - - #[test] - fn separator_format_collides_when_subject_contains_colon() { - // Document — but do not silently ignore — the colon-separator - // format's known ambiguity. A colon inside subject_id collides - // with one inside the raw value: both - // subject="alice:foo", raw="bar" - // and - // subject="alice", raw="foo:bar" - // hash the same string "alice:foo:bar" and thus produce the - // same session key. - // - // JWT `sub` claims are conventionally opaque URNs or emails, - // which in practice don't carry colons. This test asserts the - // collision exists so a future migration that introduces - // colon-bearing subject IDs (or changes the separator format - // unilaterally) breaks the build and forces a deliberate - // re-design — most likely a length-prefixed format like - // `{sub_len}:{sub}:{raw}`. - let a = subject_scoped(Some("alice:foo"), "bar").unwrap(); - let b = subject_scoped(Some("alice"), "foo:bar").unwrap(); - assert_eq!( - a, b, - "current format collides; if subject IDs can contain colons, switch to length-prefix", - ); - } - - #[test] - fn header_x_cpex_session_id_is_ignored() { - // The Python apl-plugins resolver honored an `X-CPEX-Session-Id` - // header tier between token_claim and identity. We deliberately - // dropped it: an authenticated client could set the header to - // another subject's session id and inherit their accumulated - // taints, or to a random unused value and escape their own - // tainted session. This test pins that behaviour: the header is - // present, no token claim exists, and the resolver still falls - // through to identity-derived (or none) rather than honoring - // the header. If a future PR adds a header tier without - // subject binding, this test fails. - let sec = SecurityExtension { - subject: Some(subject_with_claims(Some("alice"), &[])), - caller_workload: Some(WorkloadIdentity { - client_id: Some("agent-007".into()), - ..Default::default() - }), - ..Default::default() - }; - let mut http = HttpExtension::default(); - http.request_headers - .insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into()); - let ext = Extensions { - security: Some(Arc::new(sec)), - http: Some(Arc::new(http)), - ..Default::default() - }; - - let (sid, src) = resolve_session(&ext).expect("identity should still hit"); - assert_eq!( - src, - SessionSource::Identity, - "header tier was removed; resolver must NOT honor X-CPEX-Session-Id", - ); - assert_ne!( - sid, "sess-bob-stolen", - "header value must never become the session id", - ); - } -} diff --git a/crates/apl-cpex/src/session_store.rs b/crates/apl-cpex/src/session_store.rs deleted file mode 100644 index c70e22a9..00000000 --- a/crates/apl-cpex/src/session_store.rs +++ /dev/null @@ -1,222 +0,0 @@ -// Location: ./crates/apl-cpex/src/session_store.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `SessionStore` — pluggable backend for cross-request session state. -// v0 surface is intentionally tiny: monotonic label append + load. That -// covers `extensions.security.labels` persistence, which is the only -// session-scoped state APL needs today. -// -// # Why a trait -// -// State that survives between requests in the same session (accumulated -// taint labels, delegation history, conversation context) needs to be -// pluggable: in-memory for tests and single-process deployments, Redis -// or DynamoDB for distributed ones. The previous Python implementation -// had a `SessionState` abstraction with the same shape; this is the -// Rust port. Only the labels surface lands in v0 — delegation hops, -// conversation history, and arbitrary KV come when their consumers do. -// -// # String-typed deliberately -// -// The trait stays string-typed (`Vec` for labels) rather than -// reaching into cpex-core's `MonotonicSet` so non-CMF bridges -// (future apl-mcp, apl-langgraph, etc.) can reuse it without dragging -// CPEX types into their surface. `CmfPluginInvoker` does the -// hydration/persistence into/out of `Extensions.security.labels`. - -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, RwLock}; - -use async_trait::async_trait; - -/// Error returned by a `SessionStore` when the backing store could not -/// satisfy a request. Distributed backends (e.g. Valkey) surface -/// connectivity/timeout/protocol failures and undecodable responses -/// here so callers can **fail closed** rather than silently treating a -/// backend failure as "no accumulated labels". -/// -/// String-typed deliberately, matching the trait's own philosophy (see -/// the module header): the error stays free of backend-specific types so -/// non-CMF bridges and the cross-crate `cpex-session-valkey` backend can -/// construct it without dragging dependencies into this surface. -/// -/// Note the distinction this enables: a **positively-confirmed key-miss** -/// (unknown session) is `Ok(empty)`, NOT an error — only a genuine -/// backend failure is an `Err`. -#[derive(Debug, thiserror::Error)] -pub enum SessionStoreError { - /// The backing store was unreachable, timed out, returned an error, - /// or returned a response that could not be decoded into the - /// expected representation. Callers fail closed on this. - #[error("session store backend error: {0}")] - Backend(String), -} - -/// Pluggable session-state backend. Implementations must be `Send + Sync` -/// — the same store is shared across all concurrent requests. -/// -/// Invariants: -/// - `append_labels` is **monotonic** — labels added to a session never -/// come back out. Removal (declassification) is a separate operation -/// not covered by v0. -/// - `load_labels` for an unknown `session_id` returns `Ok(empty)` — a -/// positively-confirmed key-miss is the right response for non-session -/// traffic, and is distinct from a backend failure (`Err`). -/// - Both methods return `Result` so a distributed backend can propagate -/// failures and the caller can fail the request closed. The in-process -/// [`MemorySessionStore`] is infallible and always returns `Ok`. -#[async_trait] -pub trait SessionStore: Send + Sync { - /// Load the union of labels accumulated for the session. `Ok(empty)` - /// for new or unknown sessions (a confirmed key-miss); `Err` only on - /// a backend failure. - async fn load_labels(&self, session_id: &str) -> Result, SessionStoreError>; - - /// Append labels to the session. Existing labels are kept; new ones - /// are unioned in. Caller has already deduped against `load_labels` - /// in the hot path, but the store re-dedups defensively. `Err` only - /// on a backend failure. - async fn append_labels( - &self, - session_id: &str, - labels: &[String], - ) -> Result<(), SessionStoreError>; -} - -/// Factory the visitor consults when it encounters a -/// `global.apl.session_store` block in the unified config. Mirrors -/// [`apl_core::step::PdpFactory`]: each factory advertises a `kind()` -/// string matching the YAML block's `kind:` field, and `build` turns the -/// block into a live store. Registered up front via -/// [`crate::AplOptions::session_store_factories`]; the visitor selects -/// the active store from config during its global-config walk, before -/// any route handler captures the store. -/// -/// `build` errors are construction-time (bad config, unresolvable -/// endpoint) and surface as a config-load failure — distinct from the -/// request-time [`SessionStoreError`] the trait methods return. -pub trait SessionStoreFactory: Send + Sync { - /// The `kind:` discriminator this factory builds (e.g. `"valkey"`). - fn kind(&self) -> &str; - - /// Build a store from its config block. The whole - /// `global.apl.session_store` mapping is passed so the factory can - /// read its own keys (endpoint, TLS, auth, prefix, TTL, …). - fn build( - &self, - config: &serde_yaml::Value, - ) -> Result, Box>; -} - -/// In-process `SessionStore` backed by a `HashMap` of `HashSet`s. Suitable -/// for tests, single-process deployments, and as the default when no -/// distributed store is configured. Cloning the store via `Arc` shares -/// state across all consumers. -#[derive(Default)] -pub struct MemorySessionStore { - /// `RwLock` because reads (load_labels at request start) outnumber - /// writes (append at request end) in steady state — and lock - /// contention is bounded by the per-session level of concurrency, - /// not request volume. - inner: RwLock>>, -} - -impl MemorySessionStore { - pub fn new() -> Self { - Self::default() - } - - /// Snapshot the entire store. Test/diagnostic helper — production - /// callers should go through the trait so the backing implementation - /// stays swappable. - pub fn snapshot(&self) -> HashMap> { - self.inner.read().unwrap_or_else(|p| p.into_inner()).clone() - } -} - -#[async_trait] -impl SessionStore for MemorySessionStore { - async fn load_labels(&self, session_id: &str) -> Result, SessionStoreError> { - let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); - Ok(r.get(session_id) - .map(|s| s.iter().cloned().collect()) - .unwrap_or_default()) - } - - async fn append_labels( - &self, - session_id: &str, - labels: &[String], - ) -> Result<(), SessionStoreError> { - if labels.is_empty() { - return Ok(()); - } - let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner()); - let entry = w.entry(session_id.to_string()).or_default(); - for l in labels { - entry.insert(l.clone()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - #[tokio::test] - async fn load_for_unknown_session_is_empty() { - let store = MemorySessionStore::new(); - // Unknown session is a confirmed key-miss: Ok(empty), not Err. - assert!(store.load_labels("nonexistent").await.unwrap().is_empty()); - } - - #[tokio::test] - async fn append_then_load_roundtrips() { - let store = MemorySessionStore::new(); - store - .append_labels("sess-1", &["PII".to_string(), "INTERNAL".to_string()]) - .await - .unwrap(); - let mut labels = store.load_labels("sess-1").await.unwrap(); - labels.sort(); - assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]); - } - - #[tokio::test] - async fn append_is_monotonic_dedupes() { - let store = MemorySessionStore::new(); - store - .append_labels("sess-1", &["PII".to_string()]) - .await - .unwrap(); - store - .append_labels("sess-1", &["PII".to_string(), "PII".to_string()]) - .await - .unwrap(); - let labels = store.load_labels("sess-1").await.unwrap(); - assert_eq!(labels.len(), 1); - assert_eq!(labels[0], "PII"); - } - - #[tokio::test] - async fn sessions_are_isolated() { - let store = MemorySessionStore::new(); - store.append_labels("a", &["X".to_string()]).await.unwrap(); - store.append_labels("b", &["Y".to_string()]).await.unwrap(); - assert_eq!(store.load_labels("a").await.unwrap(), vec!["X".to_string()]); - assert_eq!(store.load_labels("b").await.unwrap(), vec!["Y".to_string()]); - } - - #[tokio::test] - async fn shared_arc_observes_writes() { - let store: Arc = Arc::new(MemorySessionStore::new()); - let c1 = Arc::clone(&store); - let c2 = Arc::clone(&store); - c1.append_labels("sess", &["Z".to_string()]).await.unwrap(); - assert_eq!(c2.load_labels("sess").await.unwrap(), vec!["Z".to_string()]); - } -} diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs deleted file mode 100644 index 8becd991..00000000 --- a/crates/apl-cpex/src/visitor.rs +++ /dev/null @@ -1,1457 +0,0 @@ -// Location: ./crates/apl-cpex/src/visitor.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `AplConfigVisitor` — the cpex-core `ConfigVisitor` implementation that -// stacks the unified-config hierarchy (global → defaults → tag bundles -// → routes) into a single `CompiledRoute` per route and installs an -// [`AplRouteHandler`] for each phase via `PluginManager::annotate_route`. -// -// # Hierarchy stacking -// -// Each `visit_*` call carries a single block of raw YAML. The visitor -// finds the `apl:` sub-block (if any), compiles it to a `CompiledRoute`, -// and stashes it in interior state: -// -// visit_global → state.global_layer -// visit_default → state.default_layers[entity_type] -// visit_policy_bundle → state.tag_layers[tag] -// visit_route → build effective route by layering and annotate. -// -// At `visit_route` we layer least-to-most-specific: -// -// effective = global -// effective.apply_layer(default_layer_for(entity_type)) -// for tag in route.meta.tags { effective.apply_layer(tag_layer(tag)) } -// effective.apply_layer(route_apl_block) -// -// then construct one `AplRouteHandler` per phase (Pre, Post) and call -// `annotate_route` for each `(entity_type, entity_name, scope, hook)`. -// -// # Hook names per entity type -// -// Each entity type binds to its own CMF hook pair: -// -// * `tool:` → `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` -// * `llm:` → `cmf.llm_input` / `cmf.llm_output` -// * `prompt:` → `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` -// * `resource:` → `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` -// -// The mapping lives in [`hook_pair_for_entity`]. Hosts fire -// `mgr.invoke_named::("cmf.llm_input", ...)` for LLM -// invocations; the visitor's annotation on `cmf.llm_input` for the -// matching route's entity_name is what AplRouteHandler intercepts. -// -// `tool_pre_invoke` / `tool_post_invoke` are exposed as legacy -// re-exports for callers that wired against the v0 constants — the -// per-entity dispatch is the load-bearing path now. - -use std::collections::HashMap; -use std::sync::{Arc, RwLock, Weak}; - -use cpex_core::cmf::constants::{ - ENTITY_HTTP, ENTITY_LLM, ENTITY_NAME_GLOBAL, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, - HOOK_CMF_HTTP_REQUEST, HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, - HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, - HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, -}; -use cpex_core::config::RouteEntry; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::PluginConfig; -use cpex_core::visitor::{ConfigVisitor, VisitorError}; - -use apl_core::attribute_source::{AttributeSource, AttributeTree}; -use apl_core::parser::compile_policy_block_value; -use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry}; -use apl_core::rules::{CompiledRoute, DenyResponse}; -use apl_core::step::{PdpFactory, PdpResolver}; - -use crate::dispatch_plan::DispatchCache; -use crate::pdp_router::PdpRouter; -use crate::route_handler::{AplRouteHandler, Phase}; -use crate::session_store::{SessionStore, SessionStoreFactory}; - -/// Legacy alias for the tool-family pre hook. Kept exported for -/// callers that wired against the v0 visitor constants — the -/// per-entity-type dispatch via `hook_pair_for_entity` is the -/// load-bearing path now. -pub const HOOK_PRE: &str = HOOK_CMF_TOOL_PRE_INVOKE; -/// Legacy alias for the tool-family post hook. See `HOOK_PRE`. -pub const HOOK_POST: &str = HOOK_CMF_TOOL_POST_INVOKE; - -/// Resolve the (pre, post) CMF hook pair for an entity_type. Drives -/// per-entity `annotate_route` calls so an `llm:` route annotates on -/// `cmf.llm_input` / `cmf.llm_output` rather than the tool-family -/// hooks. Returns `None` for unknown entity types — the visitor logs -/// + skips those routes. -fn hook_pair_for_entity(entity_type: &str) -> Option<(&'static str, &'static str)> { - match entity_type { - ENTITY_TOOL => Some((HOOK_CMF_TOOL_PRE_INVOKE, HOOK_CMF_TOOL_POST_INVOKE)), - ENTITY_LLM => Some((HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT)), - ENTITY_PROMPT => Some((HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_PROMPT_POST_INVOKE)), - ENTITY_RESOURCE => Some((HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_RESOURCE_POST_FETCH)), - _ => None, - } -} - -/// Interior state accumulated as the manager walks the visitor. -/// `plugin_registry` is populated by `visit_plugins` (called once per -/// load); the layer fields are populated as the visitor walks -/// `global` / `defaults` / `policies` / `routes`; `pdp_router` is -/// populated by both code-supplied resolvers (`register_pdp`) and -/// unified-config-driven entries under `global.apl.pdp[]` (built -/// during `visit_global`). -#[derive(Default)] -struct VisitorState { - plugin_registry: PluginRegistry, - global_layer: Option, - default_layers: HashMap, - tag_layers: HashMap, - pdp_router: PdpRouter, -} - -/// APL implementation of [`cpex_core::visitor::ConfigVisitor`]. Construct -/// once per host with the shared infrastructure (dispatch cache, session -/// store, manager handle) and register with `PluginManager::register_visitor` -/// before calling `load_config_yaml`. -/// -/// PDPs come from two sources, both feeding the same internal -/// [`PdpRouter`]: -/// -/// 1. **Code-supplied** via `register_pdp` (or `AplOptions.pdps`) — -/// the host built the resolver in code and hands it in. -/// 2. **Config-supplied** via `global.apl.pdp[]` blocks in the unified -/// config — the visitor sees the block, looks up a factory by -/// `kind`, and constructs the resolver during `visit_global`. -/// -/// Factories are registered up front by `kind` name (`"cedar-direct"`, -/// `"opa"`, …). The visitor knows nothing about specific PDP -/// backends; everything dispatches through `PdpFactory`. -pub struct AplConfigVisitor { - state: RwLock, - dispatch_cache: Arc, - /// Active session store. Behind a `RwLock` because a - /// `global.apl.session_store` block can swap it during the - /// config walk (`visit_global`), which runs before route handlers - /// capture the store in `visit_route`. Only touched during the - /// single-threaded config walk — never on the request hot path, - /// where each handler holds its own cloned `Arc`. - session_store: RwLock>, - /// Static `data.*` attribute tree, shared into every installed - /// handler. Set once before the config walk via - /// [`Self::set_attribute_tree`]; defaults to empty. Behind a `RwLock` - /// only because it's set after construction — like `session_store`, - /// it's touched only during the single-threaded config walk, never on - /// the request hot path (handlers hold their own cloned `Arc`). - attribute_tree: RwLock>, - manager: Weak, - /// Baseline capabilities granted to every synthetic `AplRouteHandler` - /// the visitor installs. Unioned with the per-route plugin - /// capability set so APL predicates that touch extensions - /// (`require(authenticated)` needs `read_subject`, etc.) work even - /// when no plugins are referenced. Hosts that want strict gating - /// can set this to an empty set. - base_capabilities: std::collections::HashSet, - /// Factories the visitor consults when it encounters a - /// `global.apl.pdp[]` entry. Keyed by the factory's `kind()` — - /// matches the `kind:` field in the YAML block. - pdp_factories: HashMap>, - /// Factories the visitor consults for a `global.apl.session_store` - /// block. Keyed by the factory's `kind()`. Empty by default, in - /// which case the constructor-supplied store (typically - /// `MemorySessionStore`) stays active. - session_store_factories: HashMap>, -} - -impl AplConfigVisitor { - pub fn new( - dispatch_cache: Arc, - session_store: Arc, - manager: Weak, - ) -> Self { - Self { - state: RwLock::new(VisitorState::default()), - dispatch_cache, - session_store: RwLock::new(session_store), - attribute_tree: RwLock::new(Arc::new(AttributeTree::empty())), - manager, - base_capabilities: default_base_capabilities(), - pdp_factories: HashMap::new(), - session_store_factories: HashMap::new(), - } - } - - /// Register a code-supplied PDP resolver. Equivalent to declaring a - /// PDP in the unified config but for hosts that prefer wiring - /// resolvers in Rust. Resolvers are pushed into the internal - /// `PdpRouter`; the first registration per dialect wins (matches - /// `PdpRouter::register` semantics). - pub fn register_pdp(&self, resolver: Arc) { - let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); - state.pdp_router.register(resolver); - } - - /// Install the static `data.*` attribute tree. Call after - /// `register_apl` and **before** `load_config_yaml` (handlers capture - /// the tree during the config walk). Load it from any - /// [`AttributeSource`](apl_core::AttributeSource) — e.g. - /// `FileAttributeSource::new(paths).load()?` — or hand-build one. - /// Replacing a previously-set tree is allowed (last set wins). - pub fn set_attribute_tree(&self, tree: AttributeTree) { - let mut slot = self - .attribute_tree - .write() - .unwrap_or_else(|p| p.into_inner()); - *slot = Arc::new(tree); - } - - /// Register a PDP factory by its `kind()`. Called during - /// `register_apl` setup; the visitor uses these to instantiate - /// resolvers from `global.apl.pdp[]` config blocks. - pub fn register_pdp_factory(&mut self, factory: Arc) { - self.pdp_factories - .insert(factory.kind().to_string(), factory); - } - - /// Register a `SessionStoreFactory` by its `kind()`. Called during - /// `register_apl` setup; the visitor uses these to swap in the - /// config-selected session store when it sees a - /// `global.apl.session_store` block. - pub fn register_session_store_factory(&mut self, factory: Arc) { - self.session_store_factories - .insert(factory.kind().to_string(), factory); - } - - /// Parse the optional `global.apl.session_store` block and swap the - /// active store. Looks up the factory by `kind`, builds the store, - /// and replaces the constructor-supplied default. Runs during - /// `visit_global` — before `visit_route` clones the store into each - /// handler — so the selected store is the one handlers capture. - /// Absent block → no-op (the default store stays active). - fn build_session_store_from_config( - &self, - block: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - let map = block.as_mapping().ok_or_else(|| { - "global.apl.session_store must be a mapping with a `kind:` field".to_string() - })?; - let kind = map - .get(serde_yaml::Value::String("kind".to_string())) - .and_then(|v| v.as_str()) - .ok_or_else(|| "global.apl.session_store missing required `kind:` field".to_string())?; - let factory = self.session_store_factories.get(kind).ok_or_else(|| { - format!( - "global.apl.session_store declared kind='{}' but no factory is registered for that \ - kind — host must call register_session_store_factory(...) before load_config_yaml", - kind - ) - })?; - let store = factory.build(block).map_err(|e| { - format!( - "global.apl.session_store (kind='{}') failed to build: {}", - kind, e - ) - })?; - *self - .session_store - .write() - .unwrap_or_else(|p| p.into_inner()) = store; - Ok(()) - } - - /// Load the static `data.*` tree from a `global.apl.attribute_files` - /// list and install it. Paths resolve relative to the process CWD - /// (the config is loaded from a string, so there is no config-file - /// directory to anchor to). Fail-fast: a missing file or a same-leaf - /// merge conflict aborts config load. - /// - /// Precedence: a tree injected via - /// [`AplConfigVisitor::set_attribute_tree`] before the config walk - /// wins — declarative `attribute_files` is skipped when a non-empty - /// tree is already present (injected > attribute_files > none). - fn build_attribute_tree_from_config( - &self, - entries: &serde_yaml::Sequence, - ) -> Result<(), VisitorError> { - { - let current = self - .attribute_tree - .read() - .unwrap_or_else(|p| p.into_inner()); - if !current.is_empty() { - tracing::info!( - "global.apl.attribute_files present but an attribute tree was already \ - injected via set_attribute_tree — keeping the injected tree \ - (injected > attribute_files)" - ); - return Ok(()); - } - } - - let mut paths = Vec::with_capacity(entries.len()); - for (i, entry) in entries.iter().enumerate() { - let s = entry.as_str().ok_or_else(|| { - format!("global.apl.attribute_files[{}] must be a string path", i) - })?; - paths.push(std::path::PathBuf::from(s)); - } - if paths.is_empty() { - return Ok(()); - } - - let tree = crate::attribute_source::FileAttributeSource::new(paths) - .load() - .map_err(|e| format!("global.apl.attribute_files failed to load: {}", e))?; - *self - .attribute_tree - .write() - .unwrap_or_else(|p| p.into_inner()) = Arc::new(tree); - Ok(()) - } - - /// Replace the baseline capability set granted to every installed - /// `AplRouteHandler`. Default covers read-only attributes APL - /// predicates commonly touch (subject, role, labels, delegation, - /// agent). Tighten this when the deployment's policy plugins - /// don't need broad reads — every cap removed is one fewer - /// extension slot a buggy predicate can leak through. - pub fn with_base_capabilities(mut self, caps: std::collections::HashSet) -> Self { - self.base_capabilities = caps; - self - } - - /// Parse one entry from `global.apl.pdp[]`. Reads `kind`, dispatches - /// to the matching factory, installs the resulting resolver into - /// the internal `PdpRouter`. Called per entry during `visit_global`. - /// - /// `index` is used only for diagnostics — operators see "the third - /// pdp entry failed" rather than a generic "a pdp entry failed." - fn build_pdp_from_config( - &self, - entry: &serde_yaml::Value, - index: usize, - ) -> Result<(), VisitorError> { - let map = entry.as_mapping().ok_or_else(|| { - format!( - "global.apl.pdp[{}] must be a mapping with a `kind:` field", - index - ) - })?; - let kind = map - .get(serde_yaml::Value::String("kind".to_string())) - .and_then(|v| v.as_str()) - .ok_or_else(|| format!("global.apl.pdp[{}] missing required `kind:` field", index))?; - let factory = self.pdp_factories.get(kind).ok_or_else(|| { - format!( - "global.apl.pdp[{}] declared kind='{}' but no factory is registered for that kind — \ - host must call register_pdp_factory(...) before load_config_yaml", - index, kind - ) - })?; - let resolver = factory.build(entry).map_err(|e| { - format!( - "global.apl.pdp[{}] (kind='{}') failed to build: {}", - index, kind, e - ) - })?; - let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); - state.pdp_router.register(resolver); - Ok(()) - } - - /// Snapshot the request-time dispatch state — plugin registry, PDP - /// router, and active session store — each `Arc`-wrapped for a handler - /// to capture. Reads the visitor's `RwLock`s once through a single - /// poison-recovery path shared by both handler-install sites - /// (`visit_global`'s entity-less HTTP handler and `visit_route`'s - /// per-entity handlers) so the policy can't diverge between them. - fn snapshot_dispatch_state( - &self, - ) -> ( - Arc, - Arc, - Arc, - ) { - let (plugin_registry, pdp_router_arc) = { - let state = self.state.read().unwrap_or_else(|p| p.into_inner()); - ( - Arc::new(state.plugin_registry.clone()), - Arc::new(state.pdp_router.clone()) as Arc, - ) - }; - let session_store = self - .session_store - .read() - .unwrap_or_else(|p| p.into_inner()) - .clone(); - (plugin_registry, pdp_router_arc, session_store) - } -} - -/// Read-only baseline for APL predicates: enough to make -/// `authenticated`, `role.*`, `perm.*`, `subject.*`, `claim.*`, -/// `subject.teams`, `security.labels`, `delegated`, `delegation.*`, -/// and `agent.*` evaluate correctly. Excludes all *write* capabilities -/// — those are granted on demand by the per-route plugin union when a -/// plugin declares `append_labels` / `append_delegation` / -/// `write_headers`. -/// -/// `read_subject` alone unlocks only `subject.id` / `subject.type`; -/// roles, permissions, teams, and claims are each gated by their own -/// capability (`read_roles` / `read_permissions` / `read_teams` / -/// `read_claims`). PDP-driven policies routinely read principal.roles / -/// principal.claims, so the baseline grants all four — tightening -/// further would surprise APL authors whose `cedar:` policies suddenly -/// see empty role sets in deployments with no plugin-declared caps. -/// Hosts that want strict subject access override this via -/// `AplOptions.base_capabilities`. -fn default_base_capabilities() -> std::collections::HashSet { - [ - "read_subject", - "read_roles", - "read_permissions", - "read_teams", - "read_claims", - "read_labels", - "read_delegation", - "read_agent", - "read_meta", - ] - .iter() - .map(|s| s.to_string()) - .collect() -} - -impl ConfigVisitor for AplConfigVisitor { - fn name(&self) -> &str { - "apl" - } - - fn visit_plugins( - &self, - _mgr: &Arc, - plugins: &[PluginConfig], - ) -> Result<(), VisitorError> { - // Translate cpex-core's typed PluginConfig into apl-core's - // PluginDeclaration. Field-for-field except `capabilities` is a - // `HashSet` on the cpex side and a `Vec` on the apl side, and - // `config` is wrapped in `serde_yaml::Value::Mapping` to match - // apl-core's opaque shape. cpex-core has already validated - // uniqueness by this point so we don't re-check. - let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); - state.plugin_registry.clear(); - for cfg in plugins { - let decl = PluginDeclaration { - name: cfg.name.clone(), - kind: cfg.kind.clone(), - hooks: cfg.hooks.clone(), - capabilities: cfg.capabilities.iter().cloned().collect(), - config: plugin_config_to_yaml(&cfg.config), - on_error: Some(on_error_to_string(&cfg.on_error)), - extra: HashMap::new(), - }; - state.plugin_registry.insert(cfg.name.clone(), decl); - } - Ok(()) - } - - fn visit_global( - &self, - mgr: &Arc, - yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - reject_legacy_apl_keys("global", yaml)?; - let Some(apl_block) = apl_subblock(yaml) else { - // No `apl:` wrapper and no flat DSL keys — there is nothing to - // compile or install. But a bare `global: { response: {...} }` - // (a denyWith with no accompanying policy) would otherwise be - // dropped here silently, before the `response_subblock` read - // below ever runs. Warn so this fail-open-by-omission case gets - // the same signal as the args/policy-empty case handled further - // down, rather than vanishing without a trace. - if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) { - tracing::warn!( - "APL visitor: global.response is set but global.apl has no policy/args block \ - — the entity-less HTTP catch-all handler will not install, so this response can never fire", - ); - } - return Ok(()); - }; - - // Process `apl.pdp[]` before stacking the pre/post-invocation - // layer — route handlers that reference PDPs need them - // resolvable by the time `visit_route` runs. - if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) { - for (i, entry) in pdp_entries.iter().enumerate() { - self.build_pdp_from_config(entry, i)?; - } - } - - // Process an optional `global.apl.session_store` block: swap the - // active store before `visit_route` clones it into handlers. - if let Some(block) = apl_block.get("session_store") { - self.build_session_store_from_config(block)?; - } - - // Process an optional `global.apl.attribute_files` list: load + - // merge the static `data.*` tree before `visit_route` clones it - // into handlers. A tree already injected via `set_attribute_tree` - // takes precedence (injected > attribute_files > none). - if let Some(files) = apl_block.get("attribute_files") { - let entries = files.as_sequence().ok_or_else(|| { - "global.apl.attribute_files must be a list of file paths".to_string() - })?; - self.build_attribute_tree_from_config(entries)?; - } - - // The `pdp:` / `session_store:` sub-keys aren't APL DSL fields; - // strip them before handing the block to - // `compile_policy_block_value` so the compiler doesn't see unknown - // keys. `compile_policy_block_value` accepts maps with - // `authorization:` / `pre_invocation:` / `post_invocation:` / - // `args:` / `result:` / `plugins:` (and inert fields it ignores), - // so a shallow strip on a clone is enough. - let policy_only = strip_non_dsl_keys(&apl_block); - let mut compiled = compile_policy_block_value("global.apl", &policy_only) - .map_err(|e| Box::new(e) as VisitorError)?; - // A `response:` block at the global scope is the catch-all denyWith. - compiled.response = response_subblock(yaml, "global"); - - // Install a catch-all handler so the global policy also evaluates for - // generic (non-MCP/A2A) HTTP requests, which carry no entity. - // Entity routes still stack `global` via apply_layer in visit_route; - // this is the *entity-less* evaluation path. Pre-phase only — - // authorization is an admission check, so there is no post handler. - let installs_pre_handler = http_catchall_should_install(&compiled); - if !installs_pre_handler && compiled.response.is_some() { - tracing::warn!( - "APL visitor: global.response is set but global.apl has no `args:`/`policy:` steps \ - — the entity-less HTTP catch-all handler will not install, so this response can never fire", - ); - } - if installs_pre_handler { - let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state(); - // Snapshot the static attribute tree (built above from - // `attribute_files`, or pre-injected). The entity-less HTTP - // catch-all handler resolves `data.*` refs against the same - // tree the entity routes do. - let attribute_tree = self - .attribute_tree - .read() - .unwrap_or_else(|p| p.into_inner()) - .clone(); - // `read_headers` (for `http.*`) is granted to every synthetic policy - // handler in `install_handler`, so the baseline is passed as-is here. - install_handler( - mgr, - ENTITY_HTTP, - ENTITY_NAME_GLOBAL, - None, - HOOK_CMF_HTTP_REQUEST, - Phase::Pre, - Arc::new(compiled.clone()), - &plugin_registry, - &self.dispatch_cache, - &session_store, - &self.manager, - Some(pdp_router_arc), - &self.base_capabilities, - attribute_tree, - ); - } - - self.state - .write() - .unwrap_or_else(|p| p.into_inner()) - .global_layer = Some(compiled); - Ok(()) - } - - fn visit_default( - &self, - _mgr: &Arc, - entity_type: &str, - yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - let source = format!("global.defaults.{}.apl", entity_type); - reject_legacy_apl_keys(&source, yaml)?; - warn_if_response_at_unsupported_scope(yaml, &format!("global.defaults.{entity_type}")); - let Some(apl_block) = apl_subblock(yaml) else { - return Ok(()); - }; - warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block); - let compiled = compile_policy_block_value(&source, &apl_block) - .map_err(|e| Box::new(e) as VisitorError)?; - self.state - .write() - .unwrap_or_else(|p| p.into_inner()) - .default_layers - .insert(entity_type.to_string(), compiled); - Ok(()) - } - - fn visit_policy_bundle( - &self, - _mgr: &Arc, - tag: &str, - yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - let source = format!("global.policies.{}.apl", tag); - reject_legacy_apl_keys(&source, yaml)?; - warn_if_response_at_unsupported_scope(yaml, &format!("global.policies.{tag}")); - let Some(apl_block) = apl_subblock(yaml) else { - return Ok(()); - }; - warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block); - let compiled = compile_policy_block_value(&source, &apl_block) - .map_err(|e| Box::new(e) as VisitorError)?; - self.state - .write() - .unwrap_or_else(|p| p.into_inner()) - .tag_layers - .insert(tag.to_string(), compiled); - Ok(()) - } - - fn visit_route( - &self, - mgr: &Arc, - yaml: &serde_yaml::Value, - parsed: &RouteEntry, - ) -> Result<(), VisitorError> { - // Extract the route's APL block (if any) and the entity identity - // we need for annotate_route. A route without an APL block AND - // without inherited layers contributes nothing — skip. - reject_legacy_apl_keys("route", yaml)?; - let route_apl = apl_subblock(yaml); - let (entity_type, entity_names) = match entity_identity(parsed) { - Some(e) => e, - None => { - tracing::warn!( - "APL visitor: route has no tool/resource/prompt/llm match — skipping", - ); - return Ok(()); - }, - }; - if let Some(block) = &route_apl { - warn_if_global_only_key_at_nonglobal_scope(&format!("routes.{entity_type}"), block); - } - let scope = parsed.meta.as_ref().and_then(|m| m.scope.clone()); - let tags: Vec = parsed - .meta - .as_ref() - .map(|m| m.tags.clone()) - .unwrap_or_default(); - - // Snapshot the dispatch state once outside the per-entity loop. - // `visit_plugins` populated the registry before any `visit_route` - // call; the router + session store were finalized in `visit_global`. - // Routes share all three, so cloning each into an `Arc` once and - // handing clones to each handler is cheaper than re-reading the - // RwLocks per entity. Cloning `PdpRouter` is refcount bumps on each - // inner resolver — cheap. - let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state(); - - // Route-level denial response (transpiled `denyWith`) — parsed once; - // its input (`yaml`) is loop-invariant across the entity names this - // route matches, so hoisting avoids re-deserializing (and - // re-warning) once per entity. `response` is scope-local: an entity - // route carries only its own block, never an inherited `global` one. - let route_response = response_subblock(yaml, &format!("routes.{entity_type}")); - - for (idx, entity_name) in entity_names.iter().enumerate() { - // route_key is what `DispatchCache` keys on, so it must - // disambiguate scoped vs unscoped routes for the same - // entity — otherwise two same-named annotations share one - // cached plan and the second's overrides leak into the first. - let route_key = match &scope { - Some(s) => format!("{}:{}@{}", entity_type, entity_name, s), - None => format!("{}:{}", entity_type, entity_name), - }; - let state = self.state.read().unwrap_or_else(|p| p.into_inner()); - - // Stack least-to-most-specific. Each apply_layer call appends - // policy/post_policy steps and merges args/result/plugin_overrides - // by field; the resulting CompiledRoute represents the route's - // effective policy in evaluation order. - let mut effective = CompiledRoute::new(&route_key); - if let Some(layer) = state.global_layer.clone() { - effective.apply_layer(layer); - } - if let Some(layer) = state.default_layers.get(entity_type).cloned() { - effective.apply_layer(layer); - } - for tag in &tags { - if let Some(layer) = state.tag_layers.get(tag).cloned() { - effective.apply_layer(layer); - } - } - drop(state); - - if let Some(block) = &route_apl { - let source = format!("routes.{}.apl", route_key); - let route_layer = compile_policy_block_value(&source, block) - .map_err(|e| Box::new(e) as VisitorError)?; - effective.apply_layer(route_layer); - } - - // Route-level denial response (transpiled `denyWith`), parsed - // above the loop. Route scope is most-specific and inheritance - // was removed, so this is the only source of `response` for an - // entity route — a malformed or absent block leaves it `None` - // (host default denial), never a leaked `global` response. - effective.response = route_response.clone(); - - // Load-time lint, once per route: flag any APL `plugins:` - // override declared for a plugin that no policy / delegate step - // references. Checked on the fully-stacked `effective` route so - // an override consumed by an inherited (global / default / tag) - // policy is not falsely flagged. The overrides and referenced - // names are entity-independent, so the first entity is - // representative — guarding on `idx == 0` keeps it to one pass. - if idx == 0 { - warn_unreferenced_plugin_overrides(&effective); - } - - // No layers contributed anything? Don't install a handler — the - // route falls back to cpex-core's plugin-chain execution. - if effective.declared_phases().is_empty() { - continue; - } - - // Plugin-mode validation for `parallel:` blocks. - // `apl-core::Effect::validate_parallel_purity` already rejected - // FieldOp / Delegate at parse time; this pass checks that every - // `plugin(X)` inside a `parallel:` references a plugin whose - // mode is safe for concurrent execution (Audit / Concurrent / - // FireAndForget). Sequential / Transform plugins would silently - // lose their mutations inside cloned branches. - // - // Looks up modes through the cpex-core PluginManager (it has - // the authoritative registration state). The lookup trait - // is `parallel_safety::PluginModeLookup`, which - // `PluginManager` implements. - if let Err(msg) = - crate::parallel_safety::validate_parallel_plugin_modes(&effective, mgr.as_ref()) - { - let err_msg = format!("route '{}': parallel-safety: {}", route_key, msg); - return Err(err_msg.into()); - } - - let route_arc = Arc::new(effective); - - // Resolve the entity-specific CMF hook pair. The visitor's - // entity_identity() already filtered out unknown types, but - // hook_pair_for_entity returning None would just skip the - // annotation rather than crash — defense in depth. - let (hook_pre, hook_post) = match hook_pair_for_entity(entity_type) { - Some(pair) => pair, - None => { - tracing::warn!( - entity_type, - entity_name, - "APL visitor: no CMF hook pair for entity_type — skipping route", - ); - continue; - }, - }; - - // Snapshot the static attribute tree (set before the walk). - // Each handler captures its own `Arc` clone — shared, not copied. - let attribute_tree = self - .attribute_tree - .read() - .unwrap_or_else(|p| p.into_inner()) - .clone(); - - // Install Pre + Post handlers. Each handler instance is bound to - // ONE phase so the executor can pick the right entry-point off - // the (entity_type, entity_name, scope, hook_name) key. - install_handler( - mgr, - entity_type, - entity_name, - scope.clone(), - hook_pre, - Phase::Pre, - Arc::clone(&route_arc), - &plugin_registry, - &self.dispatch_cache, - &session_store, - &self.manager, - Some(Arc::clone(&pdp_router_arc)), - &self.base_capabilities, - Arc::clone(&attribute_tree), - ); - install_handler( - mgr, - entity_type, - entity_name, - scope.clone(), - hook_post, - Phase::Post, - route_arc, - &plugin_registry, - &self.dispatch_cache, - &session_store, - &self.manager, - Some(Arc::clone(&pdp_router_arc)), - &self.base_capabilities, - attribute_tree, - ); - } - - Ok(()) - } -} - -#[allow(clippy::too_many_arguments)] -fn install_handler( - mgr: &Arc, - entity_type: &str, - entity_name: &str, - scope: Option, - hook_name: &str, - phase: Phase, - route: Arc, - plugin_registry: &Arc, - dispatch_cache: &Arc, - session_store: &Arc, - manager: &Weak, - pdp: Option>, - base_capabilities: &std::collections::HashSet, - attribute_tree: Arc, -) { - // Capability gating at the synthetic-handler boundary. cpex-core's - // executor calls `filter_extensions(&ext, &caps)` before every - // handler invoke — including this one. If the synthetic handler - // has fewer capabilities than its downstream plugins need, the - // executor strips extensions on the way in (so APL predicates and - // downstream plugins see empty views) and rejects mutations on the - // way out (label / delegation appends fail monotonicity checks). - // - // Granted caps = union of every plugin's caps (with per-route - // overrides applied) ∪ host-supplied baseline. The baseline - // typically covers read-only attributes APL predicates touch - // (`subject.*`, `role.*`, `delegated`, …) even when no plugins are - // referenced. - let mut capabilities = base_capabilities.clone(); - capabilities.extend(crate::dispatch_plan::route_capability_union( - &route, - plugin_registry, - )); - // Every synthetic policy handler (the entity-less HTTP catch-all, per-entity - // routes, and defaults) is granted `read_headers` so `http.*` request - // attributes are available to policy evaluation wherever the host attaches - // an `HttpExtension`. This lets an entity-route rule combine `http.*` with - // entity/`args.*` predicates in one evaluation. It is a no-op for hosts that - // never populate the HTTP extension (nothing to read). - capabilities.insert("read_headers".to_string()); - // The APL engine emits the backend candidate constraint (the `restrict` - // effect's output) into `Extensions.candidate_constraint`. That slot is - // write-gated in the executor, so the synthetic handler holds the write - // capability intrinsically — emitting its own routing output, the same - // way it emits taints. No other plugin can overwrite or drop it without - // this capability. See `cpex_core::extensions::CAP_WRITE_CANDIDATE_CONSTRAINT`. - capabilities.insert(cpex_core::extensions::CAP_WRITE_CANDIDATE_CONSTRAINT.to_string()); - - let plugin_config = PluginConfig { - name: format!( - "apl::{}::{}::{}", - entity_type, - entity_name, - if phase == Phase::Pre { "pre" } else { "post" } - ), - kind: "builtin".to_string(), - // The annotated handler covers exactly one CMF hook name. - hooks: vec![hook_name.to_string()], - capabilities, - ..Default::default() - }; - let mut handler = AplRouteHandler::new( - plugin_config.clone(), - route, - phase, - Arc::clone(plugin_registry), - Arc::clone(dispatch_cache), - Arc::clone(session_store), - manager.clone(), - ) - .with_attribute_tree(attribute_tree); - if let Some(pdp) = pdp { - handler = handler.with_pdp(pdp); - } - mgr.annotate_route( - entity_type.to_string(), - entity_name.to_string(), - scope, - hook_name.to_string(), - Arc::new(handler), - plugin_config, - ); -} - -/// Pick the route's entity identities from the first non-None match -/// field. v0: tool > resource > prompt > llm precedence. A list-form -/// match (`tool: [a, b]`) yields one annotation per element so each -/// request gets routed by its specific name. -fn entity_identity(route: &RouteEntry) -> Option<(&'static str, Vec)> { - if let Some(t) = &route.tool { - return Some(("tool", names_of(t))); - } - if let Some(r) = &route.resource { - return Some(("resource", names_of(r))); - } - if let Some(p) = &route.prompt { - return Some(("prompt", names_of(p))); - } - if let Some(l) = &route.llm { - return Some(("llm", names_of(l))); - } - None -} - -fn names_of(sol: &cpex_core::config::StringOrList) -> Vec { - match sol { - cpex_core::config::StringOrList::Single(p) => vec![p.as_str().to_string()], - cpex_core::config::StringOrList::List(v) => v.clone(), - } -} - -/// Warn when an APL block carries a global-only wiring key -/// ([`GLOBAL_ONLY_NON_DSL_KEYS`]: `pdp`, `session_store`) at a scope that -/// cannot act on it. Only [`AplConfigVisitor::visit_global`] builds PDPs -/// and selects the session store (they are process-global CPEX wiring); a -/// `pdp:` / `session_store:` written under a default / policy-bundle / -/// route block is folded into the policy body and silently discarded by -/// `compile_policy_block_value`. Surfacing it here turns that quiet no-op -/// into an actionable signal. Applies to both the flat and `apl:`-wrapped -/// forms — neither is processed off the global scope. -fn warn_if_global_only_key_at_nonglobal_scope(scope: &str, apl_block: &serde_yaml::Value) { - for key in GLOBAL_ONLY_NON_DSL_KEYS { - if apl_block.get(key).is_some() { - tracing::warn!( - scope, - key, - "APL visitor: this key is only honored under the top-level `global:` block; \ - the declaration at this scope is ignored", - ); - } - } -} - -/// Load-time lint: warn when an APL `plugins:` override is declared for a -/// plugin that no `plugin(...)` / `run(...)` policy step (or `delegate(...)` -/// step) in the effective route references. The `plugins:` map only -/// *configures* a plugin — policy steps do the *activating* — so an -/// unreferenced override has no effect and is almost always a typo or a -/// leftover. Inspects the fully-stacked route, so an override consumed by an -/// inherited (global / default / tag) policy is not falsely flagged. Called -/// once per route from `visit_route` at config-load time, never per request. -fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) { - if route.plugin_overrides.is_empty() { - return; - } - let mut referenced: std::collections::HashSet = - crate::dispatch_plan::collect_plugin_names(route) - .into_iter() - .collect(); - referenced.extend(crate::dispatch_plan::collect_delegate_plugin_names(route)); - for name in route.plugin_overrides.keys() { - if !referenced.contains(name) { - tracing::warn!( - plugin = %name, - route = %route.route_key, - "APL `plugins:` override declared for a plugin no policy step references \ - — the override has no effect (the `plugins:` map configures; policy steps activate)", - ); - } - } -} - -/// APL sub-keys that are CPEX *wiring*, not policy DSL: they are honored -/// only under the top-level `global:` block (where `visit_global` acts on -/// them) and are stripped before the remainder is handed to -/// `compile_policy_block_value`, which doesn't model them. Kept as a single -/// source of truth shared by [`strip_non_dsl_keys`] and -/// [`warn_if_global_only_key_at_nonglobal_scope`]. -const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 3] = ["pdp", "session_store", "attribute_files"]; - -/// Legacy APL config keys, mapped to their replacements. The flat-key path -/// in [`apl_subblock`] only copies recognized keys into the synthetic block, -/// so a config still using an old name would otherwise be *silently dropped* -/// here — a fail-open for `policy` / `post_policy`. We reject them loudly. -/// (The `apl:`-wrapped form is caught downstream by apl-core instead.) -const RENAMED_APL_KEYS: [(&str, &str); 2] = [ - ( - "policy", - "authorization.pre_invocation (or flat pre_invocation)", - ), - ( - "post_policy", - "authorization.post_invocation (or flat post_invocation)", - ), -]; - -/// Fail loudly when a section carries a renamed legacy APL key directly -/// (flat form). Guards the fail-open where the flat-key filter in -/// [`apl_subblock`] would otherwise drop an unrecognized `policy:` block. -fn reject_legacy_apl_keys(scope: &str, yaml: &serde_yaml::Value) -> Result<(), VisitorError> { - let Some(map) = yaml.as_mapping() else { - return Ok(()); - }; - for (old, new) in RENAMED_APL_KEYS { - if map.contains_key(serde_yaml::Value::String(old.to_string())) { - return Err(format!( - "in `{scope}`: config field `{old}` was renamed to `{new}` — update your config", - ) - .into()); - } - } - Ok(()) -} - -/// Strip the global-only wiring sub-keys ([`GLOBAL_ONLY_NON_DSL_KEYS`]) -/// from an `apl:` mapping so the remainder can be handed to -/// `compile_policy_block_value` (which doesn't model PDP / session-store -/// declarations — those are CPEX wiring concerns). Returns a clone of the -/// mapping with those keys removed; the original is left intact. -fn strip_non_dsl_keys(apl_block: &serde_yaml::Value) -> serde_yaml::Value { - let Some(map) = apl_block.as_mapping() else { - return apl_block.clone(); - }; - let mut cloned = map.clone(); - for key in GLOBAL_ONLY_NON_DSL_KEYS { - cloned.remove(serde_yaml::Value::String(key.to_string())); - } - serde_yaml::Value::Mapping(cloned) -} - -/// Bridge cpex-core's JSON-based `Option` config slot -/// into apl-core's `Option` shape. JSON is a strict -/// subset of YAML's value model so this is round-trip safe; failure -/// here would only happen if `serde_yaml::to_value` rejects a value -/// `serde_json::Value` already accepted (in practice: never). -fn plugin_config_to_yaml(cfg: &Option) -> Option { - cfg.as_ref().and_then(|v| serde_yaml::to_value(v).ok()) -} - -/// Map cpex-core's `OnError` enum onto the string shape apl-core's -/// `PluginDeclaration` carries (kept stringly-typed there because the -/// APL spec also allows custom orchestrator-defined error modes). -fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String { - on_err.to_string() -} - -/// APL keys recognized directly on a section (route / global / defaults / -/// policy-bundle) when the `apl:` wrapper is omitted. Includes the policy -/// DSL terms plus the global-only wiring keys ([`GLOBAL_ONLY_NON_DSL_KEYS`]): -/// `pdp` and `session_store` are accepted flat for parse symmetry with their -/// `apl:`-wrapped form, but only `visit_global` acts on them — at other -/// scopes they are inert and flagged by -/// [`warn_if_global_only_key_at_nonglobal_scope`]. -/// `plugins` is intentionally absent here — it is shape-ambiguous (a -/// structural plugin-ref *list* vs an apl-override *map*) and handled -/// separately in [`apl_subblock`]. -/// -/// `authorization` is the nested `{ pre_invocation, post_invocation }` -/// block; it is copied through verbatim and un-nested by apl-core's -/// `compile_policy_block_value`, so nesting lives in exactly one place. -const FLAT_APL_KEYS: [&str; 7] = [ - "pre_invocation", - "post_invocation", - "authorization", - "args", - "result", - "pdp", - "session_store", -]; - -/// Pull a section's APL block out of its raw YAML. -/// -/// The explicit `apl:` wrapper (`route -> apl -> authorization`) takes -/// precedence. When it is absent, APL terms written directly on the -/// section (`route -> authorization`) are accepted too: a synthetic block is -/// assembled from the recognized [`FLAT_APL_KEYS`] present on the -/// container, plus `plugins` when (and only when) it is a *mapping* — -/// the apl-override shape. A structural `plugins:` *list* -/// (`RouteEntry` / `PolicyGroup`) is left untouched. Returns `None` -/// when neither a wrapper nor any flat APL key is present — callers -/// treat that as "no contribution from this section" and move on. -fn apl_subblock(yaml: &serde_yaml::Value) -> Option { - // Explicit `apl:` wrapper wins. - if let Some(block) = yaml.get("apl") { - return if block.is_null() { - None - } else { - Some(block.clone()) - }; - } - - // Fallback: APL terms written directly on the section, with no - // `apl:` nesting. Copy only the unambiguous APL keys so structural - // keys (tool / identity / defaults / ...) are never misread. - let mut block = serde_yaml::Mapping::new(); - for key in FLAT_APL_KEYS { - if let Some(value) = yaml.get(key) { - block.insert(serde_yaml::Value::String(key.to_string()), value.clone()); - } - } - // `plugins` only in its apl-override (map) shape; a list is the - // structural plugin-ref form and belongs to the section's own parse. - if let Some(value) = yaml.get("plugins") { - if value.is_mapping() { - block.insert( - serde_yaml::Value::String("plugins".to_string()), - value.clone(), - ); - } - } - - if block.is_empty() { - None - } else { - Some(serde_yaml::Value::Mapping(block)) - } -} - -/// Whether the entity-less HTTP catch-all handler (Pre-phase only) should -/// install for a compiled `global` layer. Gate on both Pre-phase steps -/// (`args` + `policy`, via [`CompiledRoute::declared_phases`]), not -/// `policy` alone — an operator whose `global.apl` has only an `args:` -/// admission block (no `policy:`) must still get the catch-all installed, -/// or entity-less HTTP traffic silently bypasses it entirely (fail-open by -/// omission). -fn http_catchall_should_install(compiled: &CompiledRoute) -> bool { - let declared = compiled.declared_phases(); - declared.contains(apl_core::rules::Phase::Args) - || declared.contains(apl_core::rules::Phase::Policy) -} - -/// `response:` is not an APL DSL term (it never enters [`apl_subblock`]'s -/// [`FLAT_APL_KEYS`]) — it is documented and tested as a sibling of `apl:` -/// (`global: { apl: {...}, response: {...} }`). But an operator who mirrors -/// the `pdp:` / `session_store:` convention (which *do* work identically -/// whether flat or nested under `apl:`) may reasonably nest `response:` -/// inside `apl:` too. Accept both spellings so that mistake degrades to -/// "the other spelling wins," not "silently dropped." -/// -/// PRECEDENCE — deliberately the INVERSE of [`apl_subblock`]. `apl_subblock` -/// makes an explicit `apl:` wrapper win *entirely* over flat top-level keys -/// (for `policy:`/`pdp:`/`session_store:`); here the top-level sibling -/// `response:` wins over an `apl:`-nested one. This is intentional, not an -/// oversight: the top-level sibling is the documented, already-shipped, -/// tested form, so preferring it preserves backward compatibility, and the -/// choice can only affect the *rendered denial shape* (status/body/headers) -/// — never an Allow/Deny outcome. Do NOT "align" this with `apl_subblock`'s -/// wrapper-wins rule without a deliberate compatibility decision. -fn response_yaml_block(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> { - yaml.get("response") - .or_else(|| yaml.get("apl").and_then(|apl| apl.get("response"))) -} - -/// Warn when a `response:` block appears at a scope that never renders it. -/// A custom denial response is honored only at `global` (the entity-less -/// HTTP path) or on a route; at `default` / policy-bundle scope it is inert -/// — there is no propagation path to a handler. Mirrors the existing -/// global-only-key lint so a misplaced `response:` fails loud, not silent. -fn warn_if_response_at_unsupported_scope(yaml: &serde_yaml::Value, scope: &str) { - if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) { - tracing::warn!( - scope, - "APL visitor: `response:` is honored only at `global` or route scope; ignoring here", - ); - } -} - -/// Extract a route-level `response:` block — the transpiled `denyWith`. -/// cpex-core tolerates this out-of-band key on the route; here we -/// deserialize it into a [`DenyResponse`]. A malformed block is logged -/// and skipped (best-effort) rather than failing the whole config. -fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option { - let block = response_yaml_block(yaml)?; - if block.is_null() { - return None; - } - match serde_yaml::from_value::(block.clone()) { - Ok(resp) => Some(resp), - Err(e) => { - tracing::warn!(route = route_key, error = %e, "APL visitor: ignoring malformed route `response:` block"); - None - }, - } -} - -#[cfg(test)] -mod tests { - use super::{apl_subblock, http_catchall_should_install, response_subblock}; - use apl_core::pipeline::{FieldRule, Pipeline, Stage, TypeCheck}; - use apl_core::rules::{CompiledRoute, Effect}; - - fn yaml(s: &str) -> serde_yaml::Value { - serde_yaml::from_str(s).expect("valid yaml") - } - - fn deny_effect() -> Effect { - Effect::Deny { - reason: None, - code: None, - } - } - - fn field_rule(field: &str) -> FieldRule { - FieldRule { - field: field.to_string(), - pipeline: Pipeline { - stages: vec![Stage::Type(TypeCheck::Str)], - }, - source: "test".to_string(), - } - } - - #[test] - fn http_catchall_installs_for_args_only_global_block() { - // Regression for the fail-open-by-omission gap: a `global.apl` with - // only `args:` (no `policy:`) must still get the entity-less HTTP - // catch-all installed. Before the fix this gated on - // `!compiled.policy.is_empty()` alone, so an args-only admission - // block silently disabled authorization for all entity-less HTTP - // traffic. - let mut route = CompiledRoute::new("global"); - route.args.push(field_rule("http.method")); - assert!( - http_catchall_should_install(&route), - "an args-only global block must still install the catch-all handler" - ); - } - - #[test] - fn http_catchall_installs_for_policy_only_global_block() { - let mut route = CompiledRoute::new("global"); - route.policy.push(deny_effect()); - assert!(http_catchall_should_install(&route)); - } - - #[test] - fn http_catchall_does_not_install_for_empty_or_post_only_global_block() { - let empty = CompiledRoute::new("global"); - assert!( - !http_catchall_should_install(&empty), - "an empty global block has nothing to evaluate; installing would be a no-op handler" - ); - - let mut post_only = CompiledRoute::new("global"); - post_only.post_policy.push(deny_effect()); - assert!( - !http_catchall_should_install(&post_only), - "post_policy never runs on the Pre-phase-only catch-all, so it must not gate installation" - ); - } - - #[test] - fn response_subblock_parses_denywith() { - let v = yaml( - "tool: \"*\"\nresponse:\n status: 403\n body: \"{\\\"error\\\":\\\"forbidden\\\"}\"\n headers:\n WWW-Authenticate: \"Bearer\"\n", - ); - let resp = response_subblock(&v, "tool:*").expect("response present"); - assert_eq!(resp.status, Some(403)); - assert_eq!(resp.body.as_deref(), Some("{\"error\":\"forbidden\"}")); - assert_eq!( - resp.headers.get("WWW-Authenticate").map(String::as_str), - Some("Bearer") - ); - } - - #[test] - fn response_subblock_absent_is_none() { - let v = yaml("tool: \"*\"\npolicy:\n - \"deny\"\n"); - assert!(response_subblock(&v, "tool:*").is_none()); - } - - #[test] - fn response_subblock_nested_under_apl_wrapper_is_read() { - // An operator mirroring the pdp:/session_store: convention (which - // work identically flat or nested under `apl:`) may nest `response:` - // under `apl:` too. It must not be silently absorbed. - let v = - yaml("tool: \"*\"\napl:\n policy:\n - \"deny\"\n response:\n status: 401\n"); - let resp = response_subblock(&v, "tool:*").expect("nested response present"); - assert_eq!(resp.status, Some(401)); - } - - #[test] - fn response_subblock_top_level_wins_over_nested_apl_form() { - let v = yaml( - "tool: \"*\"\napl:\n policy:\n - \"deny\"\n response:\n status: 401\nresponse:\n status: 403\n", - ); - let resp = response_subblock(&v, "tool:*").expect("response present"); - assert_eq!( - resp.status, - Some(403), - "top-level sibling response takes precedence over the nested apl: form" - ); - } - - #[test] - fn response_subblock_malformed_is_none_not_propagated() { - // `status` must deserialize as a u16; a string value fails to parse. - // A malformed block must be dropped (warn-only), never bubble up an - // error that fails the whole config load. - let v = yaml("tool: \"*\"\nresponse:\n status: \"not-a-number\"\n"); - assert!( - response_subblock(&v, "tool:*").is_none(), - "malformed response: block must be ignored, not panic or propagate an error" - ); - } - - #[test] - fn warn_if_response_at_unsupported_scope_is_a_safe_noop() { - use super::warn_if_response_at_unsupported_scope; - // The helper only emits a tracing event; it must never panic whether - // `response:` is present or absent at a scope that can't render it. - let with_response = yaml("policy:\n - \"deny\"\nresponse:\n status: 403\n"); - let without = yaml("policy:\n - \"deny\"\n"); - warn_if_response_at_unsupported_scope(&with_response, "global.defaults.tool"); - warn_if_response_at_unsupported_scope(&with_response, "global.policies.some-tag"); - warn_if_response_at_unsupported_scope(&without, "global.defaults.tool"); - } - - #[test] - fn apl_wrapper_is_returned_as_is() { - let v = yaml("apl:\n pre_invocation:\n - \"deny\"\n"); - let block = apl_subblock(&v).expect("wrapper present"); - assert!( - block.get("pre_invocation").is_some(), - "wrapper block exposes pre_invocation" - ); - } - - #[test] - fn null_apl_wrapper_is_none() { - let v = yaml("apl: null\n"); - assert!( - apl_subblock(&v).is_none(), - "explicit null apl => no contribution" - ); - } - - #[test] - fn flat_pre_invocation_without_wrapper_is_collected() { - let v = yaml("tool: get_weather\npre_invocation:\n - \"deny\"\n"); - let block = apl_subblock(&v).expect("flat pre_invocation recognized"); - assert!( - block.get("pre_invocation").is_some(), - "flat pre_invocation lifted into the block" - ); - assert!( - block.get("tool").is_none(), - "structural keys must not leak into the apl block", - ); - } - - #[test] - fn flat_session_store_without_wrapper_is_collected() { - // A `session_store:` written directly on `global:` (no `apl:` - // wrapper) must be lifted into the block so `visit_global` can act - // on it — symmetric with the `apl:`-wrapped form and with `pdp:`. - let v = yaml("session_store:\n kind: valkey\n endpoint: localhost:6379\n"); - let block = apl_subblock(&v).expect("flat session_store recognized"); - let ss = block - .get("session_store") - .expect("session_store lifted into the block"); - assert_eq!( - ss.get("kind").and_then(|k| k.as_str()), - Some("valkey"), - "the session_store mapping is preserved intact", - ); - } - - #[test] - fn flat_plugins_map_included_but_list_excluded() { - // Map shape is the apl-override form → kept. - let m = yaml("plugins:\n audit:\n on_error: ignore\n"); - let block = apl_subblock(&m).expect("plugins map is an apl term"); - assert!(block.get("plugins").is_some(), "plugins map is kept"); - - // List shape is structural plugin-refs → not an apl block; with no - // other APL keys present, the section contributes nothing. - let l = yaml("plugins:\n - audit\n"); - assert!( - apl_subblock(&l).is_none(), - "structural plugins list must not be treated as an apl block", - ); - } - - #[test] - fn section_without_apl_terms_is_none() { - let v = yaml("tool: get_weather\n"); - assert!( - apl_subblock(&v).is_none(), - "no APL terms => no contribution" - ); - } - - #[test] - fn explicit_wrapper_wins_over_flat_keys() { - let v = yaml("apl:\n pre_invocation:\n - \"allow\"\npre_invocation:\n - \"deny\"\n"); - let block = apl_subblock(&v).expect("wrapper present"); - let pre_invocation = block - .get("pre_invocation") - .and_then(|p| p.as_sequence()) - .expect("pre_invocation sequence"); - assert_eq!(pre_invocation.len(), 1); - assert_eq!( - pre_invocation[0].as_str(), - Some("allow"), - "the explicit apl wrapper takes precedence over flat top-level keys", - ); - } - - #[test] - fn warn_if_global_only_key_at_nonglobal_scope_is_a_safe_noop() { - use super::warn_if_global_only_key_at_nonglobal_scope; - // The helper only emits a tracing event; it must never panic for - // either global-only wiring key (`pdp` / `session_store`), or for - // none present. (The drop semantics are exercised end-to-end; here - // we just guard the helper's contract.) - let with_pdp = yaml("pre_invocation:\n - \"deny\"\npdp:\n - kind: cel\n"); - let with_session_store = - yaml("pre_invocation:\n - \"deny\"\nsession_store:\n kind: valkey\n"); - let without = yaml("pre_invocation:\n - \"deny\"\n"); - warn_if_global_only_key_at_nonglobal_scope("route", &with_pdp); - warn_if_global_only_key_at_nonglobal_scope("routes.tool", &with_session_store); - warn_if_global_only_key_at_nonglobal_scope("global.defaults.tool.apl", &without); - } - - #[test] - fn unreferenced_plugin_override_is_detectable_and_lint_is_safe() { - use super::{compile_policy_block_value, warn_unreferenced_plugin_overrides}; - // A route configures two plugins but its pre_invocation only activates one: - // `used` is referenced by a `plugin(...)` step, `unused` is only - // configured. The lint relies on `collect_plugin_names` seeing the - // referenced set; verify that linkage, then that the helper runs. - let block = yaml( - "pre_invocation:\n - \"plugin(used)\"\n\ - plugins:\n used:\n on_error: ignore\n unused:\n on_error: ignore\n", - ); - let route = compile_policy_block_value("test", &block).expect("compiles"); - - let referenced = crate::dispatch_plan::collect_plugin_names(&route); - assert!( - referenced.contains(&"used".to_string()), - "pre_invocation step is referenced" - ); - assert!( - !referenced.contains(&"unused".to_string()), - "config-only override is not a reference", - ); - assert!( - route.plugin_overrides.contains_key("unused"), - "override was compiled in" - ); - - // Must not panic; it warns on `unused` and stays silent on `used`. - warn_unreferenced_plugin_overrides(&route); - } -} diff --git a/crates/apl-cpex/tests/attribute_source_e2e.rs b/crates/apl-cpex/tests/attribute_source_e2e.rs deleted file mode 100644 index 34f3dd90..00000000 --- a/crates/apl-cpex/tests/attribute_source_e2e.rs +++ /dev/null @@ -1,415 +0,0 @@ -// Location: ./crates/apl-cpex/tests/attribute_source_e2e.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end: a static `data.*` attribute tree, set on the visitor before -// the config walk, flows into every request's bag so policy predicates can -// read it. Covers the load → bag path for static dot-path references; -// `${...}` interpolation is exercised separately below. - -use std::sync::Arc; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::extensions::{ - CandidateConstraintExtension, Extensions, MetaExtension, SecurityExtension, SubjectExtension, -}; -use cpex_core::manager::PluginManager; - -use apl_cpex::{merge_attribute_docs, register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -fn cmf_payload(text: &str) -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, text), - } -} - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - meta -} - -/// Build a manager, set the given `data.*` tree, load `yaml`, initialize. -async fn build_manager_with_data(yaml: &str, data_docs_yaml: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - let visitor = register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - - // Load the tree from an in-memory "file" and install it BEFORE the - // config walk (handlers capture the tree during load_config_yaml). - let doc: serde_json::Value = serde_yaml::from_str(data_docs_yaml).unwrap(); - let tree = merge_attribute_docs([("attrs.yaml".to_string(), doc)]).unwrap(); - visitor.set_attribute_tree(tree); - - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -async fn invoke_tool(mgr: &Arc, tool: &str) -> bool { - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool(tool))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - result.continue_processing -} - -const ROUTE: &str = r#" -routes: - - tool: fetch - apl: - pre_invocation: - - "data.org.default_region == 'eu': deny('eu-restricted')" -"#; - -/// The predicate reads `data.org.default_region`; with the tree saying -/// `eu`, the deny fires. -#[tokio::test] -async fn policy_reads_data_namespace_and_denies() { - let data = "data:\n org:\n default_region: eu\n"; - let mgr = build_manager_with_data(ROUTE, data).await; - assert!( - !invoke_tool(&mgr, "fetch").await, - "data.org.default_region == 'eu' should deny" - ); -} - -/// Same route, different tree value — the predicate is false, request -/// continues. Proves the value actually comes from the tree. -#[tokio::test] -async fn policy_reads_data_namespace_and_allows() { - let data = "data:\n org:\n default_region: us\n"; - let mgr = build_manager_with_data(ROUTE, data).await; - assert!( - invoke_tool(&mgr, "fetch").await, - "data.org.default_region == 'us' should not trip the eu deny" - ); -} - -/// No tree set at all → `data.*` keys are simply absent; an equality -/// predicate against a missing key is false, so the request continues. -#[tokio::test] -async fn missing_data_key_is_absent_not_error() { - // Build without any data tree (empty docs). - let mgr = build_manager_with_data(ROUTE, "data: {}\n").await; - assert!( - invoke_tool(&mgr, "fetch").await, - "absent data.* key must not trip the deny" - ); -} - -// ----- interpolated attribute paths, end-to-end ----- - -/// Invoke with a subject id so `subject.id` lands in the bag. -async fn invoke_as_subject(mgr: &Arc, tool: &str, subject_id: &str) -> bool { - let mut subject = SubjectExtension::default(); - subject.id = Some(subject_id.to_string()); - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool(tool))), - security: Some(Arc::new(SecurityExtension { - subject: Some(subject), - ..Default::default() - })), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - result.continue_processing -} - -const INTERP_ROUTE: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - "data.agents[subject.id].region == 'eu': deny('agent pinned to eu')" -"#; - -const AGENTS_DATA: &str = r#" -data: - agents: - eu-bot: { region: eu } - us-bot: { region: us } -"#; - -/// The request's `subject.id` indexes the data tree at eval time: -/// `eu-bot` resolves to `data.agents.eu-bot.region == eu` → deny. -#[tokio::test] -async fn interpolation_resolves_subject_id_end_to_end() { - let mgr = build_manager_with_data(INTERP_ROUTE, AGENTS_DATA).await; - assert!( - !invoke_as_subject(&mgr, "infer", "eu-bot").await, - "eu-bot resolves to region=eu → deny" - ); -} - -/// Same route + tree, different caller → different resolved path → allow. -#[tokio::test] -async fn interpolation_different_subject_allows() { - let mgr = build_manager_with_data(INTERP_ROUTE, AGENTS_DATA).await; - assert!( - invoke_as_subject(&mgr, "infer", "us-bot").await, - "us-bot resolves to region=us → the eu deny does not fire" - ); -} - -// ----- data.* referenced as a restrict field value ----- - -/// Invoke as a subject and return the emitted candidate constraint, if any. -async fn constraint_as_subject( - mgr: &Arc, - tool: &str, - subject_id: &str, -) -> Option { - let mut subject = SubjectExtension::default(); - subject.id = Some(subject_id.to_string()); - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool(tool))), - security: Some(Arc::new(SecurityExtension { - subject: Some(subject), - ..Default::default() - })), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - result - .modified_extensions - .and_then(|e| e.candidate_constraint.as_ref().map(|a| (**a).clone())) -} - -const REF_ROUTE: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - restrict: - allow_models: "data.agents[subject.id].allowed_models" -"#; - -const REF_DATA: &str = r#" -data: - agents: - support-bot: { allowed_models: ["vllm/*"] } - research-bot: { allowed_models: ["anthropic/*", "vllm/*"] } -"#; - -/// One `restrict` rule, per-caller value: the `data.*` reference resolves -/// each agent's own allow-list from the static tree at request time. -#[tokio::test] -async fn restrict_field_reference_resolves_per_caller() { - let mgr = build_manager_with_data(REF_ROUTE, REF_DATA).await; - - let support = constraint_as_subject(&mgr, "infer", "support-bot") - .await - .expect("support-bot constraint"); - assert_eq!(support.allow_models, Some(vec!["vllm/*".to_string()])); - - let research = constraint_as_subject(&mgr, "infer", "research-bot") - .await - .expect("research-bot constraint"); - assert_eq!( - research.allow_models, - Some(vec!["anthropic/*".to_string(), "vllm/*".to_string()]) - ); -} - -/// An agent absent from the tree resolves to an empty allow-list — a real -/// (impossible) constraint that fails closed via `on_empty`, never an -/// unconstrained pass. -#[tokio::test] -async fn restrict_field_reference_absent_agent_is_empty() { - let mgr = build_manager_with_data(REF_ROUTE, REF_DATA).await; - let c = constraint_as_subject(&mgr, "infer", "unknown-bot") - .await - .expect("constraint still emitted"); - assert_eq!(c.allow_models, Some(vec![])); -} - -// ----- Declarative `global.apl.attribute_files` ----- - -fn default_opts() -> AplOptions { - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - } -} - -/// Write attribute files to a fresh temp dir; returns the dir + the paths. -fn write_attr_files( - tag: &str, - files: &[(&str, &str)], -) -> (std::path::PathBuf, Vec) { - let dir = std::env::temp_dir().join(format!("apl_decl_{}_{}", tag, std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let paths = files - .iter() - .map(|(name, body)| { - let p = dir.join(name); - std::fs::write(&p, body).unwrap(); - p - }) - .collect(); - (dir, paths) -} - -/// `global.apl.attribute_files` loads the tree during the config walk — -/// no `set_attribute_tree` call — and it flows into the bag. -#[tokio::test] -async fn declarative_attribute_files_load_into_bag() { - let (dir, paths) = write_attr_files( - "load", - &[("org.yaml", "data:\n org:\n default_region: eu\n")], - ); - - let yaml = format!( - r#" -global: - apl: - attribute_files: - - {path} -routes: - - tool: fetch - apl: - pre_invocation: - - "data.org.default_region == 'eu': deny('eu-restricted')" -"#, - path = paths[0].display() - ); - - let mgr = Arc::new(PluginManager::default()); - register_apl(&mgr, default_opts()); - mgr.load_config_yaml(&yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - assert!( - !invoke_tool(&mgr, "fetch").await, - "declaratively-loaded data.org.default_region == 'eu' should deny" - ); - std::fs::remove_dir_all(&dir).ok(); -} - -/// Multiple files merge (disjoint subtrees combine). -#[tokio::test] -async fn declarative_multiple_files_merge() { - let (dir, paths) = write_attr_files( - "merge", - &[ - ("org.yaml", "data:\n org:\n default_region: us\n"), - ( - "agents.yaml", - "data:\n agents:\n eu-bot:\n region: eu\n", - ), - ], - ); - - let yaml = format!( - r#" -global: - apl: - attribute_files: - - {a} - - {b} -routes: - - tool: infer - apl: - pre_invocation: - - "data.agents[subject.id].region == 'eu': deny('pinned')" -"#, - a = paths[0].display(), - b = paths[1].display() - ); - - let mgr = Arc::new(PluginManager::default()); - register_apl(&mgr, default_opts()); - mgr.load_config_yaml(&yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - assert!( - !invoke_as_subject(&mgr, "infer", "eu-bot").await, - "eu-bot → deny" - ); - std::fs::remove_dir_all(&dir).ok(); -} - -/// An injected tree (set_attribute_tree) beats declarative attribute_files. -#[tokio::test] -async fn injected_tree_beats_declarative_files() { - let (dir, paths) = write_attr_files( - "prec", - &[("org.yaml", "data:\n org:\n default_region: eu\n")], - ); - - let yaml = format!( - r#" -global: - apl: - attribute_files: - - {path} -routes: - - tool: fetch - apl: - pre_invocation: - - "data.org.default_region == 'eu': deny('eu-restricted')" -"#, - path = paths[0].display() - ); - - let mgr = Arc::new(PluginManager::default()); - let visitor = register_apl(&mgr, default_opts()); - // Inject a tree saying `us` — must win over the file's `eu`. - let doc: serde_json::Value = - serde_yaml::from_str("data:\n org:\n default_region: us\n").unwrap(); - visitor.set_attribute_tree(merge_attribute_docs([("inj".to_string(), doc)]).unwrap()); - mgr.load_config_yaml(&yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - assert!( - invoke_tool(&mgr, "fetch").await, - "injected tree (region=us) must win over attribute_files (region=eu)" - ); - std::fs::remove_dir_all(&dir).ok(); -} - -/// A missing attribute file fails config load (fail-fast). -#[tokio::test] -async fn missing_attribute_file_fails_config_load() { - let yaml = r#" -global: - apl: - attribute_files: - - /no/such/attrs.yaml -routes: - - tool: fetch - apl: - pre_invocation: - - "require(authenticated)" -"#; - let mgr = Arc::new(PluginManager::default()); - register_apl(&mgr, default_opts()); - assert!( - mgr.load_config_yaml(yaml).is_err(), - "a missing attribute file must fail config load" - ); -} diff --git a/crates/apl-cpex/tests/canonical_authn_authz_e2e.rs b/crates/apl-cpex/tests/canonical_authn_authz_e2e.rs deleted file mode 100644 index 93660a38..00000000 --- a/crates/apl-cpex/tests/canonical_authn_authz_e2e.rs +++ /dev/null @@ -1,252 +0,0 @@ -// Location: ./crates/apl-cpex/tests/canonical_authn_authz_e2e.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// End-to-end reference for the CANONICAL APL config shape: a route that -// declares `authentication:` and `authorization:` as sibling blocks, with -// NO `apl:` wrapper. This is the form the docs teach; this test proves it -// loads and runs both phases: -// -// routes: -// - tool: get_compensation -// authentication: # cpex-core identity dispatch (identity.resolve) -// - corp-jwt -// authorization: # apl-core authorization phases -// pre_invocation: -// - "plugin(audit-log)" -// -// The two blocks are handled by different layers — `authentication:` binds -// identity plugins for the `identity.resolve` hook (cpex-core), while -// `authorization:` compiles into the APL route handler the visitor installs -// on `cmf.tool_pre_invoke` (apl-cpex). Dropping `apl:` is what lets them sit -// at the same level. This test exercises both from one loaded config. - -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::PluginError as CoreError; -use cpex_core::extensions::MetaExtension; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; -use cpex_core::registry::AnyHookHandler; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -// --------------------------------------------------------------------- -// `authentication:` side — a minimal identity resolver that records it -// fired, registered under the `identity.resolve` hook. -// --------------------------------------------------------------------- - -struct RecordingIdentity { - cfg: PluginConfig, - name: String, - ledger: Arc>>, -} - -#[async_trait] -impl Plugin for RecordingIdentity { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RecordingIdentity { - async fn handle( - &self, - _payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - self.ledger.lock().unwrap().push(self.name.clone()); - PluginResult::allow() - } -} - -struct RecordingIdentityFactory { - ledger: Arc>>, -} - -impl PluginFactory for RecordingIdentityFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(RecordingIdentity { - cfg: config.clone(), - name: config.name.clone(), - ledger: Arc::clone(&self.ledger), - }); - let adapter: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); - Ok(PluginInstance { - plugin: plugin as Arc, - handlers: vec![(HOOK_IDENTITY_RESOLVE, adapter)], - }) - } -} - -// --------------------------------------------------------------------- -// `authorization:` side — an allow-through CMF plugin the APL -// `pre_invocation` step invokes via `plugin(audit-log)`. -// --------------------------------------------------------------------- - -struct AllowGate { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for AllowGate { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AllowGate { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } -} - -struct AllowGateFactory; -impl PluginFactory for AllowGateFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AllowGate { - cfg: config.clone(), - }); - let adapter: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - Ok(PluginInstance { - plugin: plugin as Arc, - handlers: vec![("cmf.tool_pre_invoke", adapter)], - }) - } -} - -// --------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------- - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - meta -} - -fn ext_for_tool(name: &str) -> Extensions { - Extensions { - meta: Some(Arc::new(meta_for_tool(name))), - ..Default::default() - } -} - -// The canonical shape under test: `authentication:` and `authorization:` -// as siblings on the route, no `apl:` wrapper. -const CANONICAL_YAML: &str = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: identity-recorder - hooks: [identity.resolve] - - name: audit-log - kind: allow-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_compensation - authentication: - - corp-jwt - authorization: - pre_invocation: - - "plugin(audit-log)" -"#; - -async fn build_manager(ledger: Arc>>) -> Arc { - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "identity-recorder", - Box::new(RecordingIdentityFactory { ledger }), - ); - mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); - - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - - mgr.load_config_yaml(CANONICAL_YAML) - .expect("canonical authentication:+authorization: config must load"); - mgr.initialize().await.expect("initialize"); - mgr -} - -// --------------------------------------------------------------------- -// Scenario -// --------------------------------------------------------------------- - -/// The canonical config loads, and BOTH sibling blocks take effect: -/// `authentication:` dispatches the `corp-jwt` identity plugin on -/// `identity.resolve`, and `authorization.pre_invocation` runs the -/// `audit-log` plugin on `cmf.tool_pre_invoke` and allows the request. -#[tokio::test] -async fn canonical_authn_and_authz_blocks_both_run() { - let ledger = Arc::new(Mutex::new(Vec::new())); - let mgr = build_manager(Arc::clone(&ledger)).await; - - // authentication: — the route's identity block dispatches corp-jwt. - let (id_result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - IdentityPayload::new("eyJ.fake.jwt", TokenSource::Bearer), - ext_for_tool("get_compensation"), - None, - ) - .await; - assert!( - id_result.continue_processing, - "identity resolve should continue; violation = {:?}", - id_result.violation - ); - assert_eq!( - *ledger.lock().unwrap(), - vec!["corp-jwt".to_string()], - "the route's `authentication:` block must dispatch corp-jwt", - ); - - // authorization: — the route's pre_invocation runs audit-log → allow. - let (authz_result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - MessagePayload { - message: Message::text(Role::User, "read my compensation"), - }, - ext_for_tool("get_compensation"), - None, - ) - .await; - assert!( - authz_result.continue_processing, - "authorization.pre_invocation should allow; violation = {:?}", - authz_result.violation - ); -} diff --git a/crates/apl-cpex/tests/capability_gating.rs b/crates/apl-cpex/tests/capability_gating.rs deleted file mode 100644 index 9845889d..00000000 --- a/crates/apl-cpex/tests/capability_gating.rs +++ /dev/null @@ -1,449 +0,0 @@ -// Location: ./crates/apl-cpex/tests/capability_gating.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Capability-gating end-to-end. cpex-core's executor calls -// `filter_extensions(&ext, &caps)` before every handler invoke — so the -// synthetic `AplRouteHandler` must declare a capability set wide enough -// to cover every downstream plugin it dispatches, otherwise: -// -// - APL predicates read from a stripped attribute bag (silently wrong -// policy decisions). -// - Downstream plugins receive a doubly-filtered view (their own caps -// applied on top of an already-stripped one). -// - Write attempts (append_labels, append_delegation, write_headers) -// fail the monotonicity check on the way back out of the handler. -// -// These tests verify the visitor computes -// `base_capabilities ∪ per-route plugin union` and sets it on the -// synthetic `PluginConfig`. - -use std::sync::Arc; - -use async_trait::async_trait; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::PluginError as CoreError; -use cpex_core::extensions::{MetaExtension, SecurityExtension}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -// ===================================================================== -// Fixtures -// ===================================================================== - -/// Plugin that records whether it saw `security.labels` populated. -/// Used to verify that `read_labels` capability propagates through the -/// synthetic handler so the inner plugin's filtered view actually -/// contains labels. -struct LabelReader { - cfg: PluginConfig, - observed_labels: Arc>>, -} - -#[async_trait] -impl Plugin for LabelReader { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for LabelReader { - async fn handle( - &self, - _payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let seen: Vec = extensions - .security - .as_ref() - .map(|s| s.labels.iter().cloned().collect()) - .unwrap_or_default(); - *self.observed_labels.lock().unwrap() = seen; - PluginResult::allow() - } -} - -struct LabelReaderFactory { - observed_labels: Arc>>, -} - -impl PluginFactory for LabelReaderFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(LabelReader { - cfg: config.clone(), - observed_labels: Arc::clone(&self.observed_labels), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Plugin that appends a label via `modify_extensions`. Used to verify -/// write-cap propagation: requires both an `append_labels` declaration -/// on the plugin AND the synthetic handler to also be granted -/// `append_labels` so the executor accepts the mutation on the way -/// back out. -struct LabelWriter { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for LabelWriter { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for LabelWriter { - async fn handle( - &self, - _payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let mut owned = extensions.cow_copy(); - let security = owned.security.get_or_insert_with(Default::default); - security.add_label("APPENDED"); - PluginResult::modify_extensions(owned) - } -} - -struct LabelWriterFactory; -impl PluginFactory for LabelWriterFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(LabelWriter { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -// ===================================================================== -// Helpers -// ===================================================================== - -fn cmf_payload(text: &str) -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, text), - } -} - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - meta -} - -fn extensions_with_label(label: &str) -> Extensions { - let mut security = SecurityExtension::default(); - security.add_label(label.to_string()); - Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - security: Some(Arc::new(security)), - ..Default::default() - } -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Plugin declares `read_labels`; route references it; pre-existing -/// label `EXISTING` is set on the request extensions. The plugin must -/// observe the label — proving the synthetic `AplRouteHandler` got -/// `read_labels` from the per-route plugin union (cpex-core's filter -/// would otherwise strip security.labels at the handler boundary). -#[tokio::test] -async fn plugin_with_read_labels_sees_labels_through_apl_handler() { - const YAML: &str = r#" -plugins: - - name: label-reader - kind: label-reader - hooks: [cmf.tool_pre_invoke] - capabilities: [read_labels] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(label-reader)" -"#; - - let observed = Arc::new(std::sync::Mutex::new(Vec::new())); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "label-reader", - Box::new(LabelReaderFactory { - observed_labels: Arc::clone(&observed), - }), - ); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let ext = extensions_with_label("EXISTING"); - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - assert!( - result.continue_processing, - "plugin shouldn't deny: {:?}", - result.violation - ); - - let seen = observed.lock().unwrap().clone(); - assert_eq!( - seen, - vec!["EXISTING".to_string()], - "plugin must observe the EXISTING label that the request carried; \ - empty means the synthetic AplRouteHandler stripped security.labels \ - because its cap union didn't include read_labels" - ); -} - -/// Same plugin shape, but DON'T declare `read_labels` on the plugin -/// and set an empty `base_capabilities` so neither the per-route -/// union nor the baseline grants the cap. The plugin must NOT see -/// labels — confirms the negative case (capability gating actually -/// hides things when caps are missing). -#[tokio::test] -async fn plugin_without_read_labels_sees_stripped_view() { - const YAML: &str = r#" -plugins: - - name: label-reader - kind: label-reader - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(label-reader)" -"#; - - let observed = Arc::new(std::sync::Mutex::new(Vec::new())); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "label-reader", - Box::new(LabelReaderFactory { - observed_labels: Arc::clone(&observed), - }), - ); - // Strict mode: empty baseline → only per-plugin caps grant - // anything, and the plugin declared none. - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: Some(std::collections::HashSet::new()), - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let ext = extensions_with_label("EXISTING"); - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - assert!(result.continue_processing); - - let seen = observed.lock().unwrap().clone(); - assert!( - seen.is_empty(), - "plugin should see no labels when neither it nor the baseline \ - grants read_labels — got: {:?}", - seen - ); -} - -/// Plugin declares `append_labels` and emits a new label via -/// `modify_extensions`. The synthetic `AplRouteHandler` must also be -/// granted `append_labels` (from the per-route union) so its outer -/// modify_extensions write doesn't get rejected on the way back out. -/// After the invoke, the appended label must be visible in the final -/// extensions. -#[tokio::test] -async fn write_capabilities_propagate_through_apl_handler() { - const YAML: &str = r#" -plugins: - - name: label-writer - kind: label-writer - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(label-writer)" -"#; - - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("label-writer", Box::new(LabelWriterFactory)); - register_apl(&mgr, AplOptions::in_process()); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - assert!( - result.continue_processing, - "label-writer should allow: {:?}", - result.violation - ); - - // The appended label should be visible on the way out via - // `modified_extensions` — None means no plugin wrote anything, - // which would be a failure here. - let modified = result - .modified_extensions - .expect("label-writer should have modified extensions"); - let labels: Vec = modified - .security - .as_ref() - .map(|s| s.labels.iter().cloned().collect()) - .unwrap_or_default(); - assert!( - labels.contains(&"APPENDED".to_string()), - "expected APPENDED to land in final security.labels — \ - a missing label means the executor rejected the write on the \ - way out of AplRouteHandler (no append_labels cap on the synthetic). \ - Got: {:?}", - labels - ); -} - -/// Predicate-only route: no plugins, just `require(authenticated)`. -/// APL evaluates this against the attribute bag built from the -/// (capability-filtered) Extensions view the handler sees. Default -/// baseline grants `read_subject`, so `authenticated` evaluates to -/// `true` when subject is present. -#[tokio::test] -async fn predicate_only_route_uses_baseline_capabilities() { - const YAML: &str = r#" -plugins: [] -routes: - - tool: get_weather - apl: - pre_invocation: - - "require(authenticated)" -"#; - let mgr = Arc::new(PluginManager::default()); - register_apl(&mgr, AplOptions::in_process()); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - // Set subject id so `authenticated` derives true via apl-cmf. - let mut security = SecurityExtension::default(); - security.subject = Some(cpex_core::extensions::SubjectExtension { - id: Some("alice".to_string()), - ..Default::default() - }); - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - security: Some(Arc::new(security)), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - assert!( - result.continue_processing, - "require(authenticated) should pass with subject.id set: violation = {:?}", - result.violation - ); -} - -/// Same predicate-only route but baseline is forcibly empty AND no -/// subject is set. With empty baseline the synthetic handler has no -/// caps, so security.subject is stripped → `authenticated` evaluates -/// false → `require(authenticated)` denies. Confirms the baseline -/// actually controls what predicates can read. -#[tokio::test] -async fn empty_baseline_strips_predicate_view() { - const YAML: &str = r#" -plugins: [] -routes: - - tool: get_weather - apl: - pre_invocation: - - "require(authenticated)" -"#; - let mgr = Arc::new(PluginManager::default()); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: Some(std::collections::HashSet::new()), - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - // Even though subject.id IS set, the empty baseline means the - // synthetic handler can't read subject — predicate sees missing → - // false → require denies. - let mut security = SecurityExtension::default(); - security.subject = Some(cpex_core::extensions::SubjectExtension { - id: Some("alice".to_string()), - ..Default::default() - }); - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - security: Some(Arc::new(security)), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - assert!( - !result.continue_processing, - "empty baseline should cause require(authenticated) to deny \ - even with subject set — capability gating proves it can't see" - ); -} diff --git a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs deleted file mode 100644 index 512445da..00000000 --- a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs +++ /dev/null @@ -1,1285 +0,0 @@ -// Location: ./crates/apl-cpex/tests/cmf_invoker_dispatch.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Integration tests for `CmfPluginInvoker` — exercises the typed -// dispatch path end-to-end against a real `cpex-core::PluginManager` -// with hand-rolled test plugins. v0 coverage: -// - `Step` invocation against an allow-plugin → `Decision::Allow` -// - `Step` invocation against a deny-plugin → `Decision::Deny` with -// reason + rule_source pulled from the CPEX `PluginViolation` -// - `Field` invocation against a modify-plugin → `Decision::Allow` -// with `modified_value` populated from the rewritten text content -// - Payload mutation persists across invocations (one modifying -// plugin's output is visible to the next). - -use std::sync::Arc; - -use async_trait::async_trait; -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError as CoreError, PluginViolation}; -use cpex_core::extensions::{SecurityExtension, SubjectExtension}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; -use cpex_core::registry::{HookEntry, PluginRef}; - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::step::{PluginInvocation, PluginInvoker}; - -use apl_cpex::{CmfPluginInvoker, MemorySessionStore, RouteDispatchPlan}; - -/// Build a single-plugin RouteDispatchPlan straight off the cpex-core -/// registry — no APL CompiledRoute involved. Used by the invoker-primitive -/// tests below to exercise the plan-based dispatch path without standing -/// up a full route. -fn plan_for( - manager: &cpex_core::manager::PluginManager, - plugin_name: &str, -) -> Arc { - let entry = RouteDispatchPlan::resolve_plugin(manager, plugin_name) - .expect("plugin must be registered with the manager"); - let mut plugins = std::collections::HashMap::new(); - plugins.insert(plugin_name.to_string(), entry); - Arc::new(RouteDispatchPlan { - plugins, - delegation_entries: Default::default(), - elicitation_entries: Default::default(), - }) -} - -// --------------------------------------------------------------------- -// Test plugins — minimal CMF handlers with hard-coded behavior so the -// dispatch path is exercised without external state. -// --------------------------------------------------------------------- - -struct AllowPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for AllowPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AllowPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } -} - -struct AllowPluginFactory; -impl PluginFactory for AllowPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -struct DenyPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for DenyPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for DenyPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(PluginViolation::new( - "policy.forbidden", - "test-fixture denied this call", - )) - } -} - -struct DenyPluginFactory; -impl PluginFactory for DenyPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Modify plugin — rewrites every Text part by appending `" [MODIFIED]"` -/// so the test can assert mutation propagation deterministically. -struct ModifyPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for ModifyPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for ModifyPlugin { - async fn handle( - &self, - payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let new_content: Vec = payload - .message - .content - .iter() - .map(|part| match part { - ContentPart::Text { text } => ContentPart::Text { - text: format!("{} [MODIFIED]", text), - }, - other => other.clone(), - }) - .collect(); - PluginResult::modify_payload(MessagePayload { - message: Message { - schema_version: payload.message.schema_version.clone(), - role: payload.message.role, - content: new_content, - channel: payload.message.channel, - }, - }) - } -} - -struct ModifyPluginFactory; -impl PluginFactory for ModifyPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(ModifyPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.field_redact", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Redactor that rewrites **only** `ToolResult.content`, leaving every -/// Text part byte-identical. This is the shape of a real PII/secret -/// redactor, and the shape whose mutation used to vanish: nothing about -/// the message's text changes, so text-based change detection reports -/// "unmodified" and the unredacted result gets forwarded. -struct RedactToolResultPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for RedactToolResultPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RedactToolResultPlugin { - async fn handle( - &self, - payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let new_content: Vec = payload - .message - .content - .iter() - .map(|part| match part { - ContentPart::ToolResult { content } => { - let mut redacted = content.clone(); - redacted.content = serde_json::Value::String("[REDACTED]".to_string()); - ContentPart::ToolResult { content: redacted } - }, - other => other.clone(), - }) - .collect(); - PluginResult::modify_payload(MessagePayload { - message: Message { - schema_version: payload.message.schema_version.clone(), - role: payload.message.role, - content: new_content, - channel: payload.message.channel, - }, - }) - } -} - -struct RedactToolResultPluginFactory; -impl PluginFactory for RedactToolResultPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(RedactToolResultPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -// --------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------- - -fn payload_with_text(text: &str) -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, text), - } -} - -/// A message carrying a tool result alongside a text part. The text part -/// is what any text-based comparison would see; the secret lives in the -/// tool result, where only a structural reader finds it. -fn payload_with_tool_result(text: &str, result: &str) -> MessagePayload { - MessagePayload { - message: Message::with_content( - Role::Tool, - vec![ - ContentPart::Text { - text: text.to_string(), - }, - ContentPart::ToolResult { - content: cpex_core::cmf::ToolResult { - tool_call_id: "tc_001".to_string(), - tool_name: "get_secret".to_string(), - content: serde_json::Value::String(result.to_string()), - is_error: false, - }, - }, - ], - ), - } -} - -/// Read the first `ToolResult.content` out of a payload. -fn tool_result_content(payload: &MessagePayload) -> Option<&serde_json::Value> { - payload.message.content.iter().find_map(|part| match part { - ContentPart::ToolResult { content } => Some(&content.content), - _ => None, - }) -} - -fn empty_bag() -> AttributeBag { - AttributeBag::new() -} - -/// Build a manager, register one factory + one plugin under the given -/// kind, and return the wired manager ready for invocation. -async fn build_manager(factory_kind: &str, factory: Box) -> Arc { - let mgr = PluginManager::default(); - mgr.register_factory(factory_kind, factory); - - let yaml = format!("plugins:\n - name: {0}\n kind: {0}\n", factory_kind); - let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); - mgr.load_config(cfg).expect("load_config"); - mgr.initialize().await.expect("initialize"); - Arc::new(mgr) -} - -// --------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------- - -#[tokio::test] -async fn step_invocation_allow_returns_decision_allow() { - let mgr = build_manager("allow-plugin", Box::new(AllowPluginFactory)).await; - let plan = plan_for(&mgr, "allow-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let outcome = invoker - .invoke( - "allow-plugin", - &empty_bag(), - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - assert_eq!(outcome.decision, Decision::Allow); - assert!(outcome.modified_value.is_none()); -} - -#[tokio::test] -async fn step_invocation_deny_surfaces_violation_reason_and_code() { - let mgr = build_manager("deny-plugin", Box::new(DenyPluginFactory)).await; - let plan = plan_for(&mgr, "deny-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let outcome = invoker - .invoke( - "deny-plugin", - &empty_bag(), - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - match outcome.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!(reason.as_deref(), Some("test-fixture denied this call")); - assert_eq!(rule_source, "policy.forbidden"); - }, - other => panic!("expected Decision::Deny, got {:?}", other), - } -} - -#[tokio::test] -async fn field_invocation_modify_surfaces_modified_value_and_persists_payload() { - let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; - let plan = plan_for(&mgr, "modify-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let value = serde_json::Value::String("hello".to_string()); - let outcome = invoker - .invoke( - "modify-plugin", - &bag, - PluginInvocation::Field { - name: "content", - value: &value, - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - assert_eq!(outcome.decision, Decision::Allow); - assert_eq!( - outcome.modified_value, - Some(serde_json::Value::String("hello [MODIFIED]".to_string())) - ); - - // Payload mutation persisted: a second invocation sees the updated - // text as input (modifier appends [MODIFIED] each pass). - let outcome2 = invoker - .invoke( - "modify-plugin", - &bag, - PluginInvocation::Field { - name: "content", - value: &value, - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - assert_eq!( - outcome2.modified_value, - Some(serde_json::Value::String( - "hello [MODIFIED] [MODIFIED]".to_string() - )) - ); -} - -#[tokio::test] -async fn current_payload_reflects_accumulated_mutations() { - let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; - let plan = plan_for(&mgr, "modify-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let value = serde_json::Value::String("ignored".to_string()); - let _ = invoker - .invoke( - "modify-plugin", - &bag, - PluginInvocation::Field { - name: "content", - value: &value, - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - let final_payload = invoker.current_payload().await; - assert_eq!(final_payload.message.get_text_content(), "hello [MODIFIED]"); -} - -// --------------------------------------------------------------------- -// Mutation signalling — the invoker reports whether a plugin handed back -// a payload, so the host never has to guess from message content. A -// guess based on text can't see a rewritten tool result, tool call, -// thinking block, image, or any other non-text part. -// -// Not covered here: a plugin returning a payload of the wrong concrete -// type (the downcast-failure path that warns and drops). `HookHandler` -// is typed on `PluginResult`, so a foreign payload can't -// be constructed through the typed dispatch path these tests use — it -// would take a hand-rolled `AnyHookHandler` bypassing the adapter. -// --------------------------------------------------------------------- - -#[tokio::test] -async fn no_mutation_reported_before_any_dispatch() { - let mgr = build_manager("allow-plugin", Box::new(AllowPluginFactory)).await; - let plan = plan_for(&mgr, "allow-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - assert!( - !invoker.payload_was_modified(), - "a fresh invoker has dispatched nothing, so nothing can have mutated" - ); -} - -#[tokio::test] -async fn plugin_that_allows_without_mutating_reports_no_mutation() { - let mgr = build_manager("allow-plugin", Box::new(AllowPluginFactory)).await; - let plan = plan_for(&mgr, "allow-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let _ = invoker - .invoke( - "allow-plugin", - &bag, - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - assert!( - !invoker.payload_was_modified(), - "a plain allow carries no payload; reporting a mutation here would \ - make every request look modified" - ); -} - -#[tokio::test] -async fn text_mutation_is_reported() { - let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; - let plan = plan_for(&mgr, "modify-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let value = serde_json::Value::String("hello".to_string()); - let _ = invoker - .invoke( - "modify-plugin", - &bag, - PluginInvocation::Field { - name: "content", - value: &value, - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - assert!(invoker.payload_was_modified()); -} - -/// The reported bug, at the invoker layer: a redactor rewrites only -/// `ToolResult.content`, so the message's text is byte-identical before -/// and after. The mutation must still be reported. -#[tokio::test] -async fn tool_result_only_mutation_is_reported() { - let mgr = build_manager("redact-plugin", Box::new(RedactToolResultPluginFactory)).await; - let plan = plan_for(&mgr, "redact-plugin"); - let original = payload_with_tool_result("here is the result", "sk-secret-value"); - let text_before = original.message.get_text_content(); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - original, - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let _ = invoker - .invoke( - "redact-plugin", - &bag, - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - let final_payload = invoker.current_payload().await; - assert_eq!( - tool_result_content(&final_payload), - Some(&serde_json::Value::String("[REDACTED]".to_string())), - "the redaction must land in the shared payload" - ); - assert_eq!( - final_payload.message.get_text_content(), - text_before, - "fixture sanity: the text is untouched, so text comparison sees no change" - ); - assert!( - invoker.payload_was_modified(), - "the mutation is invisible to text comparison but must still be reported" - ); -} - -// --------------------------------------------------------------------- -// Field-stage dispatch — a plugin invoked from an `args:` / `result:` -// pipeline is handed the whole message, so its new value for the field -// in focus has to be read back out of the part that field came from. -// Reporting the message's concatenated text instead would overwrite a -// structured argument with unrelated content. -// --------------------------------------------------------------------- - -/// Rewrites one named tool-call argument, leaving other arguments and -/// all text parts alone. -struct ArgRewritePlugin { - cfg: PluginConfig, - arg: &'static str, -} - -#[async_trait] -impl Plugin for ArgRewritePlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for ArgRewritePlugin { - async fn handle( - &self, - payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let content: Vec = payload - .message - .content - .iter() - .map(|part| match part { - ContentPart::ToolCall { content } => { - let mut next = content.clone(); - next.arguments.insert( - self.arg.to_string(), - serde_json::Value::String("[REDACTED]".to_string()), - ); - ContentPart::ToolCall { content: next } - }, - other => other.clone(), - }) - .collect(); - PluginResult::modify_payload(MessagePayload { - message: Message { - schema_version: payload.message.schema_version.clone(), - role: payload.message.role, - content, - channel: payload.message.channel, - }, - }) - } -} - -struct ArgRewriteFactory { - arg: &'static str, -} - -impl PluginFactory for ArgRewriteFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(ArgRewritePlugin { - cfg: config.clone(), - arg: self.arg, - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.field_redact", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Rewrites one field inside an object-shaped tool result. The Post-phase -/// counterpart to `ArgRewritePlugin`. -struct ResultFieldRewritePlugin { - cfg: PluginConfig, - field: &'static str, -} - -#[async_trait] -impl Plugin for ResultFieldRewritePlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for ResultFieldRewritePlugin { - async fn handle( - &self, - payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let content: Vec = payload - .message - .content - .iter() - .map(|part| match part { - ContentPart::ToolResult { content } => { - let mut next = content.clone(); - if let Some(obj) = next.content.as_object_mut() { - obj.insert( - self.field.to_string(), - serde_json::Value::String("[REDACTED]".to_string()), - ); - } - ContentPart::ToolResult { content: next } - }, - other => other.clone(), - }) - .collect(); - PluginResult::modify_payload(MessagePayload { - message: Message { - schema_version: payload.message.schema_version.clone(), - role: payload.message.role, - content, - channel: payload.message.channel, - }, - }) - } -} - -struct ResultFieldRewriteFactory { - field: &'static str, -} - -impl PluginFactory for ResultFieldRewriteFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(ResultFieldRewritePlugin { - cfg: config.clone(), - field: self.field, - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.field_redact", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -fn payload_with_tool_call(city: &str, note: &str) -> MessagePayload { - MessagePayload { - message: Message::with_content( - Role::User, - vec![ - ContentPart::Text { - text: note.to_string(), - }, - ContentPart::ToolCall { - content: cpex_core::cmf::ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [("city".to_string(), serde_json::json!(city))] - .into_iter() - .collect(), - namespace: None, - }, - }, - ], - ), - } -} - -/// The field in focus is `city`, and the plugin rewrites `city`. The new -/// value must be the redacted city, not the message's text. -#[tokio::test] -async fn field_dispatch_reports_the_field_the_plugin_rewrote() { - let mgr = build_manager("arg-redactor", Box::new(ArgRewriteFactory { arg: "city" })).await; - let plan = plan_for(&mgr, "arg-redactor"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_tool_call("London", "unrelated chatter"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let value = serde_json::json!("London"); - let outcome = invoker - .invoke( - "arg-redactor", - &bag, - PluginInvocation::Field { - name: "city", - value: &value, - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - assert_eq!( - outcome.modified_value, - Some(serde_json::json!("[REDACTED]")) - ); -} - -/// The field in focus is `city`, but the plugin rewrote `token`. The -/// pipeline must be told the field is unchanged — and the payload -/// mutation must still be recorded, so the rewrite isn't lost. -#[tokio::test] -async fn field_dispatch_reports_no_change_when_another_field_was_rewritten() { - let mgr = build_manager("arg-redactor", Box::new(ArgRewriteFactory { arg: "token" })).await; - let plan = plan_for(&mgr, "arg-redactor"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_tool_call("London", "unrelated chatter"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let value = serde_json::json!("London"); - let outcome = invoker - .invoke( - "arg-redactor", - &bag, - PluginInvocation::Field { - name: "city", - value: &value, - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - - assert_eq!( - outcome.modified_value, None, - "the field in focus is untouched, so the pipeline must leave it alone" - ); - assert!( - invoker.payload_was_modified(), - "the rewrite of another field still has to reach the host" - ); -} - -/// Post-phase dispatch reads the field out of the *result* projection, -/// not the args one. A `result:` pipeline stage that rewrites one field -/// of a structured tool result must get that field back. -#[tokio::test] -async fn post_phase_field_dispatch_reads_the_result_projection() { - let mgr = build_manager( - "result-redactor", - Box::new(ResultFieldRewriteFactory { field: "ssn" }), - ) - .await; - let plan = plan_for(&mgr, "result-redactor"); - let payload = MessagePayload { - message: Message::with_content( - Role::Tool, - vec![ContentPart::ToolResult { - content: cpex_core::cmf::ToolResult { - tool_call_id: "tc_001".to_string(), - tool_name: "get_employee".to_string(), - content: serde_json::json!({"name": "Ada", "ssn": "123-45-6789"}), - is_error: false, - }, - }], - ), - }; - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload, - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let bag = empty_bag(); - let value = serde_json::json!("123-45-6789"); - let outcome = invoker - .invoke( - "result-redactor", - &bag, - PluginInvocation::Field { - name: "ssn", - value: &value, - phase: apl_core::step::DispatchPhase::Post, - }, - ) - .await - .expect("invoke"); - - assert_eq!( - outcome.modified_value, - Some(serde_json::json!("[REDACTED]")), - "Post phase must read the field back out of the tool result, not the args" - ); -} - -// --------------------------------------------------------------------- -// Capability gating — APL route override of `capabilities:` materializes -// a derived PluginRef wrapping the same plugin Arc with a merged -// TrustedConfig. cpex-core's executor then enforces the narrower caps -// in its single per-entry `filter_extensions` pass — no double filter, -// no second clone of security. The base plugin's circuit breaker stays -// isolated. -// --------------------------------------------------------------------- - -/// Capture-plugin fixture — records the Extensions it actually receives -/// from the executor so the test can assert what survived filtering. -struct CapturePlugin { - cfg: PluginConfig, - captured: Arc>>, -} - -#[async_trait] -impl Plugin for CapturePlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for CapturePlugin { - async fn handle( - &self, - _payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - *self.captured.lock().await = Some(extensions.clone()); - PluginResult::allow() - } -} - -struct CapturePluginFactory { - slot: Arc>>, -} - -impl PluginFactory for CapturePluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(CapturePlugin { - cfg: config.clone(), - captured: self.slot.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Build a manager whose registered plugin holds the given capability -/// set (wide caps in this test — the override is supposed to narrow -/// what these caps would have allowed). -async fn build_manager_with_caps( - factory_kind: &str, - factory: Box, - cpex_caps: &[&str], -) -> Arc { - let mgr = PluginManager::default(); - mgr.register_factory(factory_kind, factory); - let caps_yaml = if cpex_caps.is_empty() { - String::new() - } else { - format!(" capabilities: [{}]\n", cpex_caps.join(", ")) - }; - let yaml = format!( - "plugins:\n - name: {0}\n kind: {0}\n{1}", - factory_kind, caps_yaml, - ); - let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); - mgr.load_config(cfg).expect("load_config"); - mgr.initialize().await.expect("initialize"); - Arc::new(mgr) -} - -fn extensions_with_subject_and_labels() -> Extensions { - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.subject = Some(SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }); - Extensions { - security: Some(Arc::new(security)), - ..Default::default() - } -} - -/// Build a RoutePluginEntry that wraps the base plugin's handler with a -/// derived PluginRef carrying narrower caps — same plugin Arc, fresh -/// circuit breaker, smaller cap set. Mirrors what -/// `RouteDispatchPlan::build` does when APL declares a route-level -/// `plugins..capabilities:` override. -fn plan_with_narrowed_caps( - manager: &PluginManager, - plugin_name: &str, - narrowed_caps: &[&str], -) -> Arc { - let base = manager - .find_plugin_entries(plugin_name) - .into_iter() - .next() - .expect("plugin registered"); - let (_hook_name, base_entry) = base; - let mut merged = base_entry.plugin_ref.trusted_config().clone(); - merged.capabilities = narrowed_caps.iter().map(|s| s.to_string()).collect(); - let override_ref = Arc::new(PluginRef::new( - Arc::clone(base_entry.plugin_ref.plugin()), - merged, - )); - let entry = HookEntry { - plugin_ref: override_ref, - handler: Arc::clone(&base_entry.handler), - }; - let mut plugins = std::collections::HashMap::new(); - let mut entries_by_hook = std::collections::HashMap::new(); - entries_by_hook.insert("cmf.tool_pre_invoke".to_string(), entry); - plugins.insert( - plugin_name.to_string(), - apl_cpex::RoutePluginEntry { - plugin_name: plugin_name.to_string(), - entries_by_hook, - }, - ); - Arc::new(apl_cpex::RouteDispatchPlan { - plugins, - delegation_entries: Default::default(), - elicitation_entries: Default::default(), - }) -} - -#[tokio::test] -async fn route_override_caps_narrow_what_plugin_sees() { - // cpex-core registers the plugin with WIDE caps: read_subject AND - // read_labels. Without an override, the plugin would see both. - let captured = Arc::new(tokio::sync::Mutex::new(None)); - let factory = CapturePluginFactory { - slot: captured.clone(), - }; - let mgr = build_manager_with_caps( - "capture-plugin", - Box::new(factory), - &["read_subject", "read_labels"], - ) - .await; - - // APL route override narrows to ONLY read_subject — labels should - // be stripped despite cpex-core having registered them. - let plan = plan_with_narrowed_caps(&mgr, "capture-plugin", &["read_subject"]); - - let invoker = CmfPluginInvoker::for_request( - mgr, - extensions_with_subject_and_labels(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - let outcome = invoker - .invoke( - "capture-plugin", - &empty_bag(), - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("invoke"); - assert_eq!(outcome.decision, Decision::Allow); - - let captured = captured.lock().await.clone().expect("handler ran"); - let security = captured.security.expect("security extension present"); - - // read_subject is in the narrowed set → subject still visible. - assert!( - security.subject.is_some(), - "route override declared read_subject; plugin should see subject" - ); - assert_eq!( - security.subject.as_ref().unwrap().id.as_deref(), - Some("alice") - ); - - // read_labels is NOT in the narrowed set → labels stripped, even - // though cpex-core's registration would have allowed them through. - assert!( - security.labels.is_empty(), - "route override dropped read_labels; labels should be empty (got {:?})", - security.labels, - ); -} - -// --------------------------------------------------------------------- -// Hook routing table regression -// --------------------------------------------------------------------- -// -// Multi-hook plugin selection bug regression: a plugin registered -// under BOTH `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` must -// dispatch to the right entry per phase. Previously the -// dispatch plan classified both as "step" and arbitrary "first -// non-field wins" picked one for every dispatch — silent wrong -// routing when policy and post_policy needed different handlers. - -/// Pre-side handler — returns Allow with no modification. -struct PreSideHandler { - cfg: PluginConfig, -} -#[async_trait] -impl Plugin for PreSideHandler { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} -impl HookHandler for PreSideHandler { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } -} - -/// Post-side handler — returns Deny with a distinctive violation -/// code so the test can assert "which handler fired" from the -/// outcome alone. -struct PostSideHandler { - cfg: PluginConfig, -} -#[async_trait] -impl Plugin for PostSideHandler { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} -impl HookHandler for PostSideHandler { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(cpex_core::error::PluginViolation::new( - "test.multi_hook.post_fired", - "post handler fired", - )) - } -} - -/// Marker plugin held by the PluginInstance (handlers are -/// independent structs — the marker satisfies the -/// `PluginInstance.plugin` field). -struct MultiHookMarker { - cfg: PluginConfig, -} -#[async_trait] -impl Plugin for MultiHookMarker { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -struct MultiHookPluginFactory; -impl PluginFactory for MultiHookPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let marker = Arc::new(MultiHookMarker { - cfg: config.clone(), - }); - let pre = Arc::new(PreSideHandler { - cfg: config.clone(), - }); - let post = Arc::new(PostSideHandler { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: marker as Arc, - handlers: vec![ - ( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(pre)), - ), - ( - "cmf.tool_post_invoke", - Arc::new(TypedHandlerAdapter::::new(post)), - ), - ], - }) - } -} - -/// Plugin registered under both `cmf.tool_pre_invoke` and -/// `cmf.tool_post_invoke`. `PluginInvocation::Step { phase: Pre }` -/// must pick the pre-side handler; `Step { phase: Post }` must pick -/// the post-side handler. The post handler emits a distinctive -/// violation code so we can prove WHICH handler fired from the -/// outcome alone — not just that "a handler" fired. -#[tokio::test] -async fn multi_hook_plugin_dispatches_per_phase_via_routing_table() { - let mgr = build_manager("multi-hook-plugin", Box::new(MultiHookPluginFactory)).await; - let plan = plan_for(&mgr, "multi-hook-plugin"); - let invoker = CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - payload_with_text("hello"), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"); - - // Pre phase — should hit pre handler → Allow. - let pre_outcome = invoker - .invoke( - "multi-hook-plugin", - &empty_bag(), - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Pre, - }, - ) - .await - .expect("pre invoke"); - assert_eq!(pre_outcome.decision, Decision::Allow); - - // Post phase — should hit post handler → Deny with the - // distinctive code. Proves the post handler ran, not the pre - // handler (which would have returned Allow). - let post_outcome = invoker - .invoke( - "multi-hook-plugin", - &empty_bag(), - PluginInvocation::Step { - phase: apl_core::step::DispatchPhase::Post, - }, - ) - .await - .expect("post invoke"); - match post_outcome.decision { - Decision::Deny { rule_source, .. } => { - assert_eq!( - rule_source, "test.multi_hook.post_fired", - "Post phase should dispatch to the post-side handler", - ); - }, - d => panic!("expected Deny from post handler, got {d:?}"), - } -} diff --git a/crates/apl-cpex/tests/config_override.rs b/crates/apl-cpex/tests/config_override.rs deleted file mode 100644 index ef20dab2..00000000 --- a/crates/apl-cpex/tests/config_override.rs +++ /dev/null @@ -1,524 +0,0 @@ -// Location: ./crates/apl-cpex/tests/config_override.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Route-level `config:` override propagation. The unified-config spec -// allows a route to declare `plugins..config: { ... }` that -// REPLACES (not merges) the plugin's base config for THIS route only. -// -// Under the hood: -// -// 1. `AplConfigVisitor` parses the override into `CompiledRoute.plugin_overrides`. -// 2. `RouteDispatchPlan::build` calls `manager.build_override_entries(name, config, caps, on_error)`. -// 3. cpex-core's `build_override_entries` invokes the plugin factory -// with the merged `PluginConfig`, calls `initialize()` on the -// result, and wraps every returned handler in a fresh `PluginRef` -// with an independent circuit breaker. -// -// These tests prove the value the route declared actually reaches the -// plugin's `Plugin::config()` (factory was called with the override) -// and that the base instance is unaffected when a *separate* route -// uses the base config. - -use std::sync::Arc; - -use async_trait::async_trait; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError as CoreError, PluginViolation}; -use cpex_core::extensions::MetaExtension; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -// ===================================================================== -// Fixtures -// ===================================================================== - -/// Plugin that reads its OWN `config.allowlist` (a list of strings) and -/// denies the request unless `"open"` is in the list. The point is that -/// each instance (base vs override) reads from its own -/// `Plugin::config()` — which is set at factory-construction time. -/// If the route override never reaches the factory, the override -/// instance has the base config and the gate behaves the same as base. -struct AllowlistGate { - cfg: PluginConfig, -} - -impl AllowlistGate { - fn allowlist(&self) -> Vec { - self.cfg - .config - .as_ref() - .and_then(|v| v.get("allowlist")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|x| x.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default() - } -} - -#[async_trait] -impl Plugin for AllowlistGate { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AllowlistGate { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - if self.allowlist().iter().any(|s| s == "open") { - PluginResult::allow() - } else { - PluginResult::deny(PluginViolation::new( - "policy.config_gate", - format!( - "allowlist does not include 'open' — saw {:?}", - self.allowlist() - ), - )) - } - } -} - -/// Counter so we can prove the factory was invoked again for the -/// override route (i.e. a *new* instance, not a shared one). Two -/// `mgr.invoke_named` calls against two different routes should -/// trigger exactly two factory calls: one at `load_config` for the -/// base, one at `build_override_entries` for the override route. The -/// dispatch cache memoizes the override entry, so subsequent invokes -/// against the same route don't re-instantiate. -struct AllowlistGateFactory { - instance_count: Arc, -} - -impl PluginFactory for AllowlistGateFactory { - fn create(&self, config: &PluginConfig) -> Result> { - self.instance_count - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let plugin = Arc::new(AllowlistGate { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -// ===================================================================== -// Helpers -// ===================================================================== - -fn cmf_payload() -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, "x"), - } -} - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - meta -} - -async fn build_manager(yaml: &str) -> (Arc, Arc) { - let instance_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "allowlist-gate", - Box::new(AllowlistGateFactory { - instance_count: Arc::clone(&instance_count), - }), - ); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - (mgr, instance_count) -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Base config: `allowlist: ["closed"]` → plugin denies. Route -/// `tool_a` doesn't override, so it uses the base — should deny. -/// Route `tool_b` overrides `allowlist: ["open"]` → factory builds a -/// new instance with that config → plugin allows. Proves the override -/// reaches the factory and the new instance reads it. -#[tokio::test] -async fn config_override_replaces_base_config_for_route() { - const YAML: &str = r#" -plugins: - - name: gate - kind: allowlist-gate - hooks: [cmf.tool_pre_invoke] - config: - allowlist: ["closed"] -routes: - - tool: tool_a - apl: - pre_invocation: - - "plugin(gate)" - - tool: tool_b - apl: - plugins: - gate: - config: - allowlist: ["open"] - pre_invocation: - - "plugin(gate)" -"#; - let (mgr, instance_count) = build_manager(YAML).await; - - // tool_a uses the base config → denies. - let ext_a = Extensions { - meta: Some(Arc::new(meta_for_tool("tool_a"))), - ..Default::default() - }; - let (res_a, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_a, None) - .await; - let v = res_a - .violation - .expect("base config has no 'open' — should deny"); - assert_eq!(v.code, "policy.config_gate"); - assert!( - v.reason.contains("\"closed\""), - "violation should report the base allowlist, got: {}", - v.reason - ); - - // tool_b uses the override → allows. - let ext_b = Extensions { - meta: Some(Arc::new(meta_for_tool("tool_b"))), - ..Default::default() - }; - let (res_b, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_b, None) - .await; - assert!( - res_b.continue_processing, - "override should allow — violation: {:?}", - res_b.violation - ); - - // Factory invoked exactly twice: once at load_config for the base, - // once at build_override_entries for tool_b. tool_a doesn't override, - // so no extra call. Subsequent invokes hit the dispatch cache. - assert_eq!( - instance_count.load(std::sync::atomic::Ordering::SeqCst), - 2, - "expected one factory call for base + one for override; \ - a different count means caching / override path is wrong" - ); -} - -/// Run tool_b twice. The dispatch cache must memoize the override -/// instance built on the first call so the second call doesn't trigger -/// another factory invocation. Two routes with overrides should still -/// produce exactly 1 + N instances (base + one per overriding route), -/// regardless of how many invokes hit each route. -#[tokio::test] -async fn dispatch_cache_memoizes_override_instances() { - const YAML: &str = r#" -plugins: - - name: gate - kind: allowlist-gate - hooks: [cmf.tool_pre_invoke] - config: - allowlist: ["closed"] -routes: - - tool: tool_b - apl: - plugins: - gate: - config: - allowlist: ["open"] - pre_invocation: - - "plugin(gate)" -"#; - let (mgr, instance_count) = build_manager(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("tool_b"))), - ..Default::default() - }; - - // Three invokes against tool_b. The factory should fire once for - // the base (at load_config) and once for the override (at the - // first dispatch); the second + third dispatches hit the cache. - for _ in 0..3 { - let (res, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext.clone(), None) - .await; - assert!(res.continue_processing, "{:?}", res.violation); - } - - assert_eq!( - instance_count.load(std::sync::atomic::Ordering::SeqCst), - 2, - "factory should be called exactly twice across three invokes — \ - the dispatch cache must reuse the override instance after the \ - first build" - ); -} - -/// Override only `on_error` (no `config:`). Per the spec, this should -/// take the fast path inside `build_override_entries`: shared base -/// plugin Arc, fresh `PluginRef` with merged `TrustedConfig`. The -/// factory must NOT be re-invoked. -#[tokio::test] -async fn caps_only_override_does_not_reinstantiate() { - const YAML: &str = r#" -plugins: - - name: gate - kind: allowlist-gate - hooks: [cmf.tool_pre_invoke] - config: - allowlist: ["open"] -routes: - - tool: tool_c - apl: - plugins: - gate: - on_error: ignore - pre_invocation: - - "plugin(gate)" -"#; - let (mgr, instance_count) = build_manager(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("tool_c"))), - ..Default::default() - }; - let (res, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - assert!(res.continue_processing); - - // Only the base instantiation happened at load_config. The override - // only changes on_error, so the shared-base PluginRef path fires - // and no factory call is made for the route variant. - assert_eq!( - instance_count.load(std::sync::atomic::Ordering::SeqCst), - 1, - "caps/on_error-only override must NOT re-invoke the factory; \ - doing so would burn resources for a trivial config diff" - ); -} - -// --------------------------------------------------------------------- -// Extended coverage — two routes with distinct config overrides for -// the same plugin, and on_error-override plumbing verification. -// --------------------------------------------------------------------- - -/// Two routes (`tool_a`, `tool_b`) reference the same plugin (`gate`) -/// with DIFFERENT config overrides. The dispatch cache must produce -/// two independent instances, one per route, each carrying its own -/// override config — proves the cache key (`route_key`) keeps the -/// instances separate. Verified by the per-route runtime behavior -/// AND by the factory-call count: base + override_a + override_b = 3. -#[tokio::test] -async fn two_routes_with_distinct_overrides_produce_distinct_instances() { - const YAML: &str = r#" -plugins: - - name: gate - kind: allowlist-gate - hooks: [cmf.tool_pre_invoke] - config: - allowlist: ["closed"] -routes: - - tool: tool_a - apl: - plugins: - gate: - config: - allowlist: ["alpha"] - pre_invocation: - - "plugin(gate)" - - tool: tool_b - apl: - plugins: - gate: - config: - allowlist: ["open"] - pre_invocation: - - "plugin(gate)" -"#; - let (mgr, instance_count) = build_manager(YAML).await; - - // tool_a override: allowlist=["alpha"] — gate denies (no "open"). - let ext_a = Extensions { - meta: Some(Arc::new(meta_for_tool("tool_a"))), - ..Default::default() - }; - let (res_a, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_a, None) - .await; - let v_a = res_a - .violation - .expect("tool_a override has no 'open' — should deny"); - assert!( - v_a.reason.contains("\"alpha\""), - "tool_a violation should report its own override allowlist (alpha), got: {}", - v_a.reason, - ); - - // tool_b override: allowlist=["open"] — gate allows. - let ext_b = Extensions { - meta: Some(Arc::new(meta_for_tool("tool_b"))), - ..Default::default() - }; - let (res_b, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_b, None) - .await; - assert!( - res_b.continue_processing, - "tool_b override has 'open' — should allow: {:?}", - res_b.violation, - ); - - // Factory invocation count: base (at load_config) + tool_a - // override (at first tool_a dispatch) + tool_b override (at first - // tool_b dispatch). Three total — proves the cache holds two - // distinct instances rather than collapsing them. - assert_eq!( - instance_count.load(std::sync::atomic::Ordering::SeqCst), - 3, - "expected 3 factory calls (base + tool_a override + tool_b override); \ - a smaller count means overrides collapsed across routes", - ); -} - -/// Override changes `on_error` only — sanity-check that the override -/// VALUE actually lands on the per-route plugin entry's trusted_config, -/// not just that the factory wasn't re-invoked. -/// -/// Counterpart to `caps_only_override_does_not_reinstantiate` (which -/// only checks the perf optimization). This test verifies the -/// PLUMBING: build the plan with and without an on_error override, -/// then read the resolved entry's trusted_config to confirm the -/// override actually flowed through (`Ignore`) vs the base default -/// (`Fail`). -#[tokio::test] -async fn on_error_override_plumbs_through_to_trusted_config() { - use std::collections::HashMap; - - use apl_core::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; - use apl_core::rules::{CompiledRoute, Effect}; - use apl_cpex::{DispatchCache, RouteDispatchPlan}; - use cpex_core::plugin::OnError; - - // Single-plugin cpex-core config — load it via the manager so the - // plugin is registered. No APL visitor / routes wiring needed — - // we'll build the routes manually below to focus on what the - // dispatch plan does with overrides. - const YAML: &str = r#" -plugins: - - name: gate - kind: allowlist-gate - hooks: [cmf.tool_pre_invoke] - config: - allowlist: ["open"] -"#; - let (mgr, _) = build_manager(YAML).await; - - // Construct the APL plugin registry by hand to match what - // `compile_config` would have produced for the YAML's `plugins:` - // block. `RouteDispatchPlan::build` consults this to know which - // plugins to resolve through cpex-core. - let mut registry = PluginRegistry::new(); - registry.insert( - "gate".to_string(), - PluginDeclaration { - name: "gate".to_string(), - kind: "allowlist-gate".to_string(), - hooks: vec!["cmf.tool_pre_invoke".to_string()], - capabilities: Vec::new(), - config: None, - on_error: None, - extra: HashMap::new(), - }, - ); - - let cache = DispatchCache::new(); - - // Override route — sets `on_error: ignore` only. - let mut route_override = CompiledRoute::default(); - route_override.route_key = "override-route".into(); - route_override.policy.push(Effect::Plugin { - name: "gate".into(), - }); - let mut override_block = PluginOverride::default(); - override_block.on_error = Some("ignore".into()); - route_override - .plugin_overrides - .insert("gate".to_string(), override_block); - let plan_override: std::sync::Arc = - cache.get_or_build(&route_override, ®istry, &mgr).await; - let entry_override = plan_override - .plugins - .get("gate") - .expect("gate must resolve on override route") - .entries_by_hook - .values() - .next() - .expect("override route entry present"); - assert_eq!( - entry_override.plugin_ref.trusted_config().on_error, - OnError::Ignore, - "override route should carry on_error=Ignore on its entry", - ); - - // Base-config route — no overrides; should carry default Fail. - let mut route_base = CompiledRoute::default(); - route_base.route_key = "base-route".into(); - route_base.policy.push(Effect::Plugin { - name: "gate".into(), - }); - let plan_base = cache.get_or_build(&route_base, ®istry, &mgr).await; - let entry_base = plan_base - .plugins - .get("gate") - .expect("gate must resolve on base route") - .entries_by_hook - .values() - .next() - .expect("base route entry present"); - assert_eq!( - entry_base.plugin_ref.trusted_config().on_error, - OnError::Fail, - "base route should carry the default on_error=Fail", - ); -} diff --git a/crates/apl-cpex/tests/delegate_step_e2e.rs b/crates/apl-cpex/tests/delegate_step_e2e.rs deleted file mode 100644 index fc5b4006..00000000 --- a/crates/apl-cpex/tests/delegate_step_e2e.rs +++ /dev/null @@ -1,933 +0,0 @@ -// Location: ./crates/apl-cpex/tests/delegate_step_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end test for `Step::Delegate` dispatch. -// -// Verifies the full flow: -// * APL parser produces a `Step::Delegate` from policy YAML. -// * apl-cpex's `RouteDispatchPlan::build` resolves the plugin's -// `token.delegate` entry into `plan.delegation_entries`. -// * apl-cpex's `DelegationPluginInvoker` constructs a -// `DelegationPayload`, dispatches via -// `invoke_entries::(...)`, applies the -// resulting payload to extensions, and surfaces granted_* -// attributes for downstream rules. -// * Downstream `require(delegation.granted.* ...)` predicates see -// the populated bag attributes (IdP-as-PDP path). -// * `on_error: deny` (the default) halts the route on plugin deny; -// `on_error: continue` lets the pipeline keep going. - -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use chrono::{Duration, Utc}; - -use cpex_core::context::PluginContext; -use cpex_core::delegation::{DelegationPayload, TokenDelegateHook, HOOK_TOKEN_DELEGATE}; -use cpex_core::error::PluginViolation; -use cpex_core::extensions::raw_credentials::{ - RawCredentialsExtension, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole, -}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; - -use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, PdpCall, PdpDecision, PdpDialect, - PdpError, PdpResolver, RoutePayload, -}; - -use apl_cpex::{ - CmfPluginInvoker, DelegationPluginInvoker, DispatchCache, MemorySessionStore, SessionStore, -}; - -// --------------------------------------------------------------------- -// Fake TokenDelegateHook plugin — records every call and produces a -// configurable response (grant scopes / deny). -// --------------------------------------------------------------------- - -#[derive(Debug, Clone)] -struct DelegateCallRecord { - plugin_name: String, - target_name: String, - target_audience: Option, - required_permissions: Vec, -} - -struct RecordingDelegate { - cfg: PluginConfig, - /// Shared ledger — tests assert on what the plugin saw. - ledger: Arc>>, - /// `Some` → mint a token with these scopes; `None` → deny with - /// the supplied violation code. - grant_scopes: Option>, - grant_audience: String, - deny_code: Option, - /// Snapshot of what extensions the plugin observed when invoked. - /// Used by capability-gating tests to verify the executor's - /// per-entry filter narrowed the view to declared caps. - observed_extensions: Arc>>, -} - -/// Compact summary of what a delegate plugin saw in `Extensions` — -/// just the slots cap-gating tests care about. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct ExtensionsObservation { - saw_subject_id: Option, - saw_labels: Vec, - saw_inbound_token_for_user: bool, - saw_delegation_chain_present: bool, -} - -#[async_trait] -impl Plugin for RecordingDelegate { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RecordingDelegate { - async fn handle( - &self, - payload: &DelegationPayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - self.ledger.lock().unwrap().push(DelegateCallRecord { - plugin_name: self.cfg.name.clone(), - target_name: payload.target_name().to_string(), - target_audience: payload.target_audience().map(str::to_string), - required_permissions: payload.required_permissions().to_vec(), - }); - - // Snapshot what this plugin sees in Extensions — the executor's - // per-entry capability filter narrows the view BEFORE handing - // it to the handler, so any slot we see proves the cap that - // gates it was declared. - *self.observed_extensions.lock().unwrap() = Some(ExtensionsObservation { - saw_subject_id: ext - .security - .as_ref() - .and_then(|s| s.subject.as_ref()) - .and_then(|s| s.id.clone()), - saw_labels: ext - .security - .as_ref() - .map(|s| s.labels.iter().cloned().collect()) - .unwrap_or_default(), - saw_inbound_token_for_user: ext - .raw_credentials - .as_ref() - .map(|rc| rc.inbound_tokens.contains_key(&TokenRole::User)) - .unwrap_or(false), - saw_delegation_chain_present: ext.delegation.is_some(), - }); - - if let Some(code) = &self.deny_code { - return PluginResult::deny(PluginViolation::new( - code.clone(), - format!("recording-delegate `{}` denied", self.cfg.name), - )); - } - - // Grant case — mint a fake token. - let scopes = self.grant_scopes.clone().unwrap_or_default(); - let token = RawDelegatedToken::new( - format!("fake.token.for.{}", self.cfg.name), - "Authorization", - self.grant_audience.clone(), - scopes, - Utc::now() + Duration::seconds(300), - ); - let mut updated = payload.clone(); - updated.delegated_token = Some(token); - PluginResult::modify_payload(updated) - } -} - -fn delegate_cfg(name: &str) -> PluginConfig { - delegate_cfg_with_caps(name, &[]) -} - -/// Same as `delegate_cfg` but with declared capabilities. Capability -/// names map to cpex-core's `filter_extensions` rules — e.g. -/// `read_subject`, `read_labels`, `read_inbound_credentials`, -/// `read_delegation`. Used by cap-gating tests. -fn delegate_cfg_with_caps(name: &str, caps: &[&str]) -> PluginConfig { - PluginConfig { - name: name.to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: caps.iter().map(|s| s.to_string()).collect(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } -} - -// --------------------------------------------------------------------- -// Stub PDP — apl-core's evaluator requires `&dyn PdpResolver`; no -// scenario here exercises a PDP step, so an always-allow stub is -// enough. -// --------------------------------------------------------------------- - -struct AllowPdp; -#[async_trait] -impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }) - } -} - -// --------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------- - -/// Build request-level Extensions with a fake inbound bearer token so -/// `DelegationPluginInvoker` has something to put in the -/// DelegationPayload's bearer slot. -fn ext_with_bearer(token: &str) -> Extensions { - let mut raw = RawCredentialsExtension::default(); - raw.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new(token, "Authorization", TokenKind::Jwt), - ); - Extensions { - raw_credentials: Some(Arc::new(raw)), - ..Default::default() - } -} - -/// Build Extensions populated with a subject + label so cap-gating -/// tests can verify what a delegate plugin actually sees after the -/// executor's per-entry filter narrows the view to declared caps. -fn ext_with_subject_and_label(token: &str, subject_id: &str, label: &str) -> Extensions { - use cpex_core::extensions::{SecurityExtension, SubjectExtension}; - - let mut raw = RawCredentialsExtension::default(); - raw.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new(token, "Authorization", TokenKind::Jwt), - ); - - let mut sec = SecurityExtension::default(); - sec.subject = Some(SubjectExtension { - id: Some(subject_id.to_string()), - ..Default::default() - }); - sec.add_label(label); - - Extensions { - raw_credentials: Some(Arc::new(raw)), - security: Some(Arc::new(sec)), - ..Default::default() - } -} - -/// Wire up a PluginManager with one or more TokenDelegate plugins, -/// run the route YAML through apl-core's compile, and return the -/// pieces a test needs to invoke a route. -async fn build_setup( - yaml: &str, - plugins: Vec<(String, Arc, PluginConfig)>, -) -> ( - Arc, - apl_core::CompiledConfig, - Arc, -) { - let mgr = Arc::new(PluginManager::default()); - for (_, plugin, cfg) in plugins { - mgr.register_handler::(plugin, cfg) - .expect("register delegate plugin"); - } - mgr.initialize().await.expect("initialize"); - let cfg = compile_config(yaml).expect("compile route YAML"); - let cache = Arc::new(DispatchCache::new()); - (mgr, cfg, cache) -} - -// --------------------------------------------------------------------- -// Scenarios -// --------------------------------------------------------------------- - -/// Baseline: a route with one `delegate(...)` step. The plugin is -/// called with the args from the step, mints a token, and the -/// resulting `delegation.granted.*` bag attributes are visible to -/// downstream `require(...)` rules. -#[tokio::test] -async fn delegate_step_grants_visible_to_downstream_require() { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - let plugin = Arc::new(RecordingDelegate { - cfg: delegate_cfg("workday-oauth"), - ledger: Arc::clone(&ledger), - grant_scopes: Some(vec!["read_compensation".to_string()]), - grant_audience: "workday-api".to_string(), - deny_code: None, - observed_extensions: Arc::new(Mutex::new(None)), - }); - - // APL semantics: `allow` rules don't short-circuit — only `deny` - // halts. So the assertion shape is "deny if NOT granted", - // which falls through to the implicit allow at end-of-steps when - // the delegate succeeded. - let yaml = r#" -plugins: - - name: workday-oauth - kind: test - hooks: [token.delegate] -routes: - get_compensation: - pre_invocation: - - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - - "!delegation.granted: deny" - - "!(delegation.granted.permissions contains 'read_compensation'): deny" -"#; - let (mgr, cfg, cache) = build_setup( - yaml, - vec![( - "workday-oauth".to_string(), - Arc::clone(&plugin), - delegate_cfg("workday-oauth"), - )], - ) - .await; - - let route = cfg.routes.get("get_compensation").expect("route present"); - let registry = cfg.plugins.clone(); - let plan = cache.get_or_build(route, ®istry, &mgr).await; - - let extensions = ext_with_bearer("eyJ.fake.user-jwt"); - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text( - cpex_core::cmf::enums::Role::User, - "fetch compensation", - ), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - let mut payload = RoutePayload::new(serde_json::Value::Null); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), - ) - .await; - - // Inspect the bag directly — this proves the evaluator wrote - // granted_* keys, giving us specific diagnostics if the route - // fails for some other reason. - assert!( - matches!( - bag.get("delegation.granted"), - Some(apl_core::attributes::AttributeValue::Bool(true)) - ), - "delegation.granted should be true; bag has: {:?}", - bag.get("delegation.granted"), - ); - let perms = bag - .get_string_set("delegation.granted.permissions") - .expect("granted.permissions present"); - assert!(perms.contains("read_compensation")); - - assert_eq!( - decision.decision, - Decision::Allow, - "route should allow; got: {:?}", - decision.decision, - ); - - // Plugin was called with the right args. - let calls = ledger.lock().unwrap().clone(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].plugin_name, "workday-oauth"); - assert_eq!(calls[0].target_name, "workday-api"); - assert_eq!(calls[0].required_permissions, vec!["read_compensation"]); - - // Extensions now carry the minted token under raw_credentials. - let final_ext = invoker.current_extensions().await; - let raw = final_ext - .raw_credentials - .as_ref() - .expect("raw_credentials present"); - assert_eq!(raw.delegated_tokens.len(), 1, "one minted token"); - let token = raw.delegated_tokens.values().next().unwrap(); - assert_eq!(token.audience, "workday-api"); - assert_eq!(token.scopes, vec!["read_compensation"]); -} - -/// IdP-as-PDP: when the plugin denies (e.g. simulating IdP refusal), -/// the route halts with the plugin's violation code — `on_error: deny` -/// is the default and translates the delegate's deny into a route -/// deny. -#[tokio::test] -async fn delegate_step_default_on_error_denies_route() { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - let plugin = Arc::new(RecordingDelegate { - cfg: delegate_cfg("workday-oauth"), - ledger: Arc::clone(&ledger), - grant_scopes: None, - grant_audience: String::new(), - deny_code: Some("delegation.idp_rejected".to_string()), - observed_extensions: Arc::new(Mutex::new(None)), - }); - - // Plugin denies. Default on_error: deny → route halts at the - // delegate step itself with the plugin's violation code. No - // downstream rule needed for the test. - let yaml = r#" -plugins: - - name: workday-oauth - kind: test - hooks: [token.delegate] -routes: - get_compensation: - pre_invocation: - - "delegate(workday-oauth, target: workday-api, permissions: [write_everything])" -"#; - let (mgr, cfg, cache) = build_setup( - yaml, - vec![( - "workday-oauth".to_string(), - Arc::clone(&plugin), - delegate_cfg("workday-oauth"), - )], - ) - .await; - - let route = cfg.routes.get("get_compensation").expect("route present"); - let registry = cfg.plugins.clone(); - let plan = cache.get_or_build(route, ®istry, &mgr).await; - - let extensions = ext_with_bearer("eyJ.fake.user-jwt"); - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text( - cpex_core::cmf::enums::Role::User, - "fetch comp", - ), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - let mut payload = RoutePayload::new(serde_json::Value::Null); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), - ) - .await; - - match decision.decision { - Decision::Deny { rule_source, .. } => { - assert_eq!( - rule_source, "delegation.idp_rejected", - "rule_source should carry the plugin's violation code", - ); - }, - d => panic!("expected Deny on plugin deny, got {d:?}"), - } - assert_eq!( - ledger.lock().unwrap().len(), - 1, - "delegate plugin was called once", - ); -} - -/// `on_error: continue` — even on plugin deny, the route keeps -/// going. Downstream rules can branch on `delegation.granted` being -/// absent. Useful for "try delegation, fall back to a different -/// flow" patterns. -#[tokio::test] -async fn delegate_step_on_error_continue_lets_pipeline_proceed() { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - let plugin = Arc::new(RecordingDelegate { - cfg: delegate_cfg("audit-receipt"), - ledger: Arc::clone(&ledger), - grant_scopes: None, - grant_audience: String::new(), - deny_code: Some("audit.unavailable".to_string()), - observed_extensions: Arc::new(Mutex::new(None)), - }); - - let yaml = r#" -plugins: - - name: audit-receipt - kind: test - hooks: [token.delegate] -routes: - any: - pre_invocation: - - "delegate(audit-receipt, target: audit, on_error: continue)" -"#; - let (mgr, cfg, cache) = build_setup( - yaml, - vec![( - "audit-receipt".to_string(), - Arc::clone(&plugin), - delegate_cfg("audit-receipt"), - )], - ) - .await; - - let route = cfg.routes.get("any").expect("route present"); - let registry = cfg.plugins.clone(); - let plan = cache.get_or_build(route, ®istry, &mgr).await; - - let extensions = ext_with_bearer("eyJ.fake.user-jwt"); - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text(cpex_core::cmf::enums::Role::User, "any"), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - let mut payload = RoutePayload::new(serde_json::Value::Null); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), - ) - .await; - - assert_eq!( - decision.decision, - Decision::Allow, - "on_error: continue → route allows despite plugin deny", - ); -} - -/// Most-recent-wins semantics for multiple `delegate(...)` calls in -/// one phase. Two delegates in a row both succeed; the -/// `delegation.granted.*` bag keys reflect the LAST one. -/// Extensions-side carries BOTH minted tokens (`raw_credentials.delegated_tokens`). -#[tokio::test] -async fn multiple_delegates_most_recent_wins_in_bag_extensions_accumulate() { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - let workday = Arc::new(RecordingDelegate { - cfg: delegate_cfg("workday-oauth"), - ledger: Arc::clone(&ledger), - grant_scopes: Some(vec!["read_compensation".to_string()]), - grant_audience: "workday-api".to_string(), - deny_code: None, - observed_extensions: Arc::new(Mutex::new(None)), - }); - let payroll = Arc::new(RecordingDelegate { - cfg: delegate_cfg("payroll-oauth"), - ledger: Arc::clone(&ledger), - grant_scopes: Some(vec!["read_salary".to_string()]), - grant_audience: "payroll-api".to_string(), - deny_code: None, - observed_extensions: Arc::new(Mutex::new(None)), - }); - - // After both delegates run, the bag reflects payroll's grants - // (most recent). The contains-check on 'read_salary' succeeds - // (because payroll's grant is what's currently in - // `delegation.granted.permissions`); a check for - // 'read_compensation' would FAIL even though workday minted a - // token with that permission, because the bag key is - // overwritten. Extensions-side accumulation (both tokens - // present) is verified separately below. - let yaml = r#" -plugins: - - name: workday-oauth - kind: test - hooks: [token.delegate] - - name: payroll-oauth - kind: test - hooks: [token.delegate] -routes: - fanout: - pre_invocation: - - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - - "delegate(payroll-oauth, target: payroll-api, permissions: [read_salary])" - - "!(delegation.granted.permissions contains 'read_salary'): deny" -"#; - let (mgr, cfg, cache) = build_setup( - yaml, - vec![ - ( - "workday-oauth".to_string(), - Arc::clone(&workday), - delegate_cfg("workday-oauth"), - ), - ( - "payroll-oauth".to_string(), - Arc::clone(&payroll), - delegate_cfg("payroll-oauth"), - ), - ], - ) - .await; - - let route = cfg.routes.get("fanout").expect("route present"); - let registry = cfg.plugins.clone(); - let plan = cache.get_or_build(route, ®istry, &mgr).await; - - let extensions = ext_with_bearer("eyJ.fake.user-jwt"); - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text(cpex_core::cmf::enums::Role::User, "fanout"), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - let mut payload = RoutePayload::new(serde_json::Value::Null); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), - ) - .await; - - assert_eq!(decision.decision, Decision::Allow); - - // Both plugins fired, in order. - let calls = ledger.lock().unwrap().clone(); - assert_eq!(calls.len(), 2); - assert_eq!(calls[0].plugin_name, "workday-oauth"); - assert_eq!(calls[1].plugin_name, "payroll-oauth"); - - // Extensions accumulate — BOTH minted tokens are stashed. - let final_ext = invoker.current_extensions().await; - let raw = final_ext.raw_credentials.as_ref().unwrap(); - assert_eq!(raw.delegated_tokens.len(), 2); - let auds: std::collections::HashSet<&str> = raw - .delegated_tokens - .values() - .map(|t| t.audience.as_str()) - .collect(); - assert!(auds.contains("workday-api")); - assert!(auds.contains("payroll-api")); -} - -// --------------------------------------------------------------------- -// Capability gating on the delegate() step path. -// -// The executor calls `filter_extensions(&ext, &caps)` per entry before -// each handler runs (executor.rs:440 in cpex-core). These tests pin -// that behavior end-to-end for the `Step::Delegate` dispatch path — -// proves that what an operator declares as `capabilities:` on a -// `token.delegate` plugin is enforced exactly the same way it is for -// CMF plugins. -// --------------------------------------------------------------------- - -/// Delegate plugin declaring `read_subject` AND `read_inbound_credentials` -/// (the inbound-credentials cap is needed because the bearer token -/// arrives via raw_credentials and the invoker passes Extensions -/// through unmodified beyond the per-entry filter). Plugin sees the -/// subject, sees the inbound bearer token, but NOT the security label -/// (no read_labels cap). -#[tokio::test] -async fn delegate_with_read_subject_sees_subject_but_not_labels() { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - let observed: Arc>> = Arc::new(Mutex::new(None)); - - let plugin_cfg = delegate_cfg_with_caps( - "scoped-delegate", - &["read_subject", "read_inbound_credentials"], - ); - let plugin = Arc::new(RecordingDelegate { - cfg: plugin_cfg.clone(), - ledger: Arc::clone(&ledger), - grant_scopes: Some(vec!["read_compensation".to_string()]), - grant_audience: "workday-api".to_string(), - deny_code: None, - observed_extensions: Arc::clone(&observed), - }); - - let yaml = r#" -plugins: - - name: scoped-delegate - kind: test - hooks: [token.delegate] -routes: - get_compensation: - pre_invocation: - - "delegate(scoped-delegate, target: workday-api, permissions: [read_compensation])" -"#; - let (mgr, cfg, cache) = build_setup( - yaml, - vec![( - "scoped-delegate".to_string(), - Arc::clone(&plugin), - plugin_cfg, - )], - ) - .await; - - let route = cfg.routes.get("get_compensation").expect("route present"); - let registry = cfg.plugins.clone(); - let plan = cache.get_or_build(route, ®istry, &mgr).await; - - // Extensions with BOTH subject (id=alice) AND a label (pii) — - // proves the cap filter is selective. - let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii"); - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text( - cpex_core::cmf::enums::Role::User, - "fetch compensation", - ), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - let mut payload = RoutePayload::new(serde_json::Value::Null); - let _ = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), - ) - .await; - - let obs = observed - .lock() - .unwrap() - .clone() - .expect("plugin should have run and recorded its view"); - - assert_eq!( - obs.saw_subject_id.as_deref(), - Some("alice"), - "read_subject cap should expose subject.id", - ); - assert!( - obs.saw_inbound_token_for_user, - "read_inbound_credentials cap should expose the inbound user token", - ); - assert!( - obs.saw_labels.is_empty(), - "without read_labels, the label should NOT leak — saw: {:?}", - obs.saw_labels, - ); -} - -/// Delegate plugin declaring NO capabilities. Should see NOTHING in -/// security or raw_credentials — the executor strips both slots -/// because no relevant cap is held. Verifies the negative case: -/// failure to declare a cap actually does hide the slot. -#[tokio::test] -async fn delegate_without_caps_sees_stripped_extensions() { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - let observed: Arc>> = Arc::new(Mutex::new(None)); - - // Empty caps array — plugin opts into nothing. - let plugin_cfg = delegate_cfg_with_caps("capless-delegate", &[]); - let plugin = Arc::new(RecordingDelegate { - cfg: plugin_cfg.clone(), - ledger: Arc::clone(&ledger), - grant_scopes: Some(vec!["read_compensation".to_string()]), - grant_audience: "workday-api".to_string(), - deny_code: None, - observed_extensions: Arc::clone(&observed), - }); - - let yaml = r#" -plugins: - - name: capless-delegate - kind: test - hooks: [token.delegate] -routes: - any: - pre_invocation: - - "delegate(capless-delegate, target: workday-api)" -"#; - let (mgr, cfg, cache) = build_setup( - yaml, - vec![( - "capless-delegate".to_string(), - Arc::clone(&plugin), - plugin_cfg, - )], - ) - .await; - - let route = cfg.routes.get("any").expect("route present"); - let registry = cfg.plugins.clone(); - let plan = cache.get_or_build(route, ®istry, &mgr).await; - - let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii"); - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text(cpex_core::cmf::enums::Role::User, "any"), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - let mut payload = RoutePayload::new(serde_json::Value::Null); - let _ = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), - ) - .await; - - let obs = observed - .lock() - .unwrap() - .clone() - .expect("plugin should have run"); - - // Load-bearing negative assertions — no cap → no slot. - assert!( - obs.saw_subject_id.is_none(), - "without read_subject, subject must be hidden — saw: {:?}", - obs.saw_subject_id, - ); - assert!( - obs.saw_labels.is_empty(), - "without read_labels, labels must be hidden", - ); - assert!( - !obs.saw_inbound_token_for_user, - "without read_inbound_credentials, inbound token must be hidden", - ); -} diff --git a/crates/apl-cpex/tests/elicit_step_e2e.rs b/crates/apl-cpex/tests/elicit_step_e2e.rs deleted file mode 100644 index 8d3468fd..00000000 --- a/crates/apl-cpex/tests/elicit_step_e2e.rs +++ /dev/null @@ -1,353 +0,0 @@ -// Location: ./crates/apl-cpex/tests/elicit_step_e2e.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end test for the elicitation bridge (Phase 2). -// -// Verifies the full flow: -// * apl-cpex's `RouteDispatchPlan::build` resolves an elicitation -// plugin's `elicit` entry into `plan.elicitation_entries` by name. -// * `ElicitationPluginInvoker` builds an `ElicitationPayload` for each -// of dispatch / check / validate (setting `ElicitationOp`), -// dispatches via `invoke_entries::(...)`, and maps -// the returned payload back to apl-core's `ElicitationDispatch` / -// `ElicitationStatus` / `ElicitationValidation`. -// * A handler deny surfaces as `ElicitationError`. - -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; - -use cpex_core::context::PluginContext; -use cpex_core::elicitation::{ - ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, - ElicitationStatusKind, HOOK_ELICIT, -}; -use cpex_core::error::PluginViolation; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; - -use apl_core::{ - compile_config, ElicitKind, ElicitStep, ElicitationInvoker, ElicitationOutcome, - ElicitationStatus, -}; -use apl_cpex::{ElicitationPluginInvoker, RouteDispatchPlan}; - -use tokio::sync::Mutex as AsyncMutex; - -// --------------------------------------------------------------------- -// Fake ElicitationHook plugin — records each operation it saw and -// produces a configurable response per operation. -// --------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq)] -struct OpRecord { - op: ElicitationOp, - kind: String, - from: String, - elicitation_id: Option, - purpose: Option, -} - -struct FakeApprover { - cfg: PluginConfig, - ledger: Arc>>, - /// What `check` reports. - check_status: ElicitationStatusKind, - check_outcome: Option, - /// What `validate` reports. - validate_valid: bool, - /// When `Some`, the handler denies (any op) with this violation code. - deny_code: Option, -} - -#[async_trait] -impl Plugin for FakeApprover { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for FakeApprover { - async fn handle( - &self, - payload: &ElicitationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - self.ledger.lock().unwrap().push(OpRecord { - op: payload.operation(), - kind: payload.kind().to_string(), - from: payload.from().to_string(), - elicitation_id: payload.elicitation_id().map(str::to_string), - purpose: payload.purpose().map(str::to_string), - }); - - if let Some(code) = &self.deny_code { - return PluginResult::deny(PluginViolation::new( - code.clone(), - "fake-approver denied".to_string(), - )); - } - - let mut out = payload.clone(); - match payload.operation() { - ElicitationOp::Dispatch => { - out.id = Some("elic-abc".to_string()); - out.status = Some(ElicitationStatusKind::Pending); - out.approver = Some(payload.from().to_string()); - out.intent_id = Some("intent-77".to_string()); - out.expires_at = Some("2026-12-31T00:00:00Z".to_string()); - }, - ElicitationOp::Check => { - out.status = Some(self.check_status); - out.outcome = self.check_outcome; - }, - ElicitationOp::Validate => { - out.valid = Some(self.validate_valid); - out.approver = Some("alice@example.com".to_string()); - out.intent_id = Some("intent-77".to_string()); - }, - } - PluginResult::modify_payload(out) - } -} - -fn approver_cfg(name: &str) -> PluginConfig { - PluginConfig { - name: name.to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec![HOOK_ELICIT.to_string()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: std::collections::HashSet::new(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } -} - -/// Build a manager with the plugin registered, compile the route YAML, -/// and build the dispatch plan for the named route. -async fn setup( - plugin: Arc, - cfg: PluginConfig, -) -> (Arc, Arc) { - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler::(plugin, cfg) - .expect("register elicitation plugin"); - mgr.initialize().await.expect("initialize"); - - let yaml = r#" -plugins: - - name: manager-approver - kind: test - hooks: [elicit] -routes: - payroll_adjust: - pre_invocation: - - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" -"#; - let cfg = compile_config(yaml).expect("compile route YAML"); - let route = cfg.routes.get("payroll_adjust").expect("route present"); - let plan = RouteDispatchPlan::build(route, &cfg.plugins, &mgr).await; - (mgr, Arc::new(plan)) -} - -fn elicit_step() -> ElicitStep { - ElicitStep { - kind: ElicitKind::Approval, - plugin_name: "manager-approver".to_string(), - channel: Some("ciba".to_string()), - from: "claim.manager".to_string(), - purpose: Some("Approve raise".to_string()), - scope: Some("args.amount <= 25000".to_string()), - timeout: None, - config_override: None, - on_error: None, - source: "payroll_adjust.policy[0]".to_string(), - } -} - -fn invoker(mgr: Arc, plan: Arc) -> ElicitationPluginInvoker { - ElicitationPluginInvoker::new(mgr, Arc::new(AsyncMutex::new(Extensions::default())), plan) -} - -// --------------------------------------------------------------------- -// Scenarios -// --------------------------------------------------------------------- - -#[tokio::test] -async fn dispatch_maps_payload_outputs_and_passes_resolved_from() { - let ledger = Arc::new(Mutex::new(Vec::new())); - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::clone(&ledger), - check_status: ElicitationStatusKind::Pending, - check_outcome: None, - validate_valid: true, - deny_code: None, - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - - let d = inv - .dispatch(&elicit_step(), "alice@example.com") - .await - .expect("dispatch ok"); - - assert_eq!(d.id, "elic-abc"); - assert_eq!(d.approver.as_deref(), Some("alice@example.com")); - assert_eq!(d.intent_id.as_deref(), Some("intent-77")); - assert_eq!(d.expires_at.as_deref(), Some("2026-12-31T00:00:00Z")); - - // The plugin saw a Dispatch op with the resolved approver + inputs. - let rec = ledger.lock().unwrap()[0].clone(); - assert_eq!(rec.op, ElicitationOp::Dispatch); - assert_eq!(rec.kind, "approval"); - assert_eq!(rec.from, "alice@example.com"); - assert_eq!(rec.purpose.as_deref(), Some("Approve raise")); - assert!(rec.elicitation_id.is_none()); -} - -#[tokio::test] -async fn check_maps_pending_and_resolved() { - // Pending. - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::new(Mutex::new(Vec::new())), - check_status: ElicitationStatusKind::Pending, - check_outcome: None, - validate_valid: true, - deny_code: None, - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - let s = inv - .check(&elicit_step(), "elic-abc") - .await - .expect("check ok"); - assert_eq!(s, ElicitationStatus::Pending); - - // Resolved + approved. - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::new(Mutex::new(Vec::new())), - check_status: ElicitationStatusKind::Resolved, - check_outcome: Some(ElicitationOutcomeKind::Approved), - validate_valid: true, - deny_code: None, - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - let s = inv - .check(&elicit_step(), "elic-abc") - .await - .expect("check ok"); - assert_eq!( - s, - ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Approved - } - ); -} - -#[tokio::test] -async fn check_resolved_without_outcome_defaults_denied() { - // Fail-safe: Resolved with no outcome must not read as approved. - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::new(Mutex::new(Vec::new())), - check_status: ElicitationStatusKind::Resolved, - check_outcome: None, - validate_valid: true, - deny_code: None, - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - let s = inv - .check(&elicit_step(), "elic-abc") - .await - .expect("check ok"); - assert_eq!( - s, - ElicitationStatus::Resolved { - outcome: ElicitationOutcome::Denied - } - ); -} - -#[tokio::test] -async fn validate_maps_verdict_and_facts() { - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::new(Mutex::new(Vec::new())), - check_status: ElicitationStatusKind::Resolved, - check_outcome: Some(ElicitationOutcomeKind::Approved), - validate_valid: true, - deny_code: None, - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - let v = inv - .validate(&elicit_step(), "elic-abc") - .await - .expect("validate ok"); - assert!(v.valid); - assert_eq!(v.approver.as_deref(), Some("alice@example.com")); - assert_eq!(v.intent_id.as_deref(), Some("intent-77")); -} - -#[tokio::test] -async fn handler_deny_surfaces_as_error() { - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::new(Mutex::new(Vec::new())), - check_status: ElicitationStatusKind::Pending, - check_outcome: None, - validate_valid: false, - deny_code: Some("channel.unavailable".to_string()), - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - let err = inv - .dispatch(&elicit_step(), "alice@example.com") - .await - .expect_err("handler denied"); - let msg = format!("{err}"); - assert!(msg.contains("channel.unavailable"), "got: {msg}"); -} - -#[tokio::test] -async fn unregistered_plugin_is_not_found() { - // Plan has no entry for a plugin the step names → NotFound, which the - // evaluator's on_error then handles. - let plugin = Arc::new(FakeApprover { - cfg: approver_cfg("manager-approver"), - ledger: Arc::new(Mutex::new(Vec::new())), - check_status: ElicitationStatusKind::Pending, - check_outcome: None, - validate_valid: true, - deny_code: None, - }); - let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; - let inv = invoker(mgr, plan); - - let mut step = elicit_step(); - step.plugin_name = "nonexistent".to_string(); - let err = inv - .dispatch(&step, "alice@example.com") - .await - .expect_err("unregistered"); - assert!(matches!( - err, - apl_core::ElicitationError::NotFound(p) if p == "nonexistent" - )); -} diff --git a/crates/apl-cpex/tests/elicit_then_delegate_e2e.rs b/crates/apl-cpex/tests/elicit_then_delegate_e2e.rs deleted file mode 100644 index dd16c985..00000000 --- a/crates/apl-cpex/tests/elicit_then_delegate_e2e.rs +++ /dev/null @@ -1,346 +0,0 @@ -// Location: ./crates/apl-cpex/tests/elicit_then_delegate_e2e.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Integration test for the composition of two effects in one route: -// an approval gate (`require_approval` → ElicitationPluginInvoker) that -// must pass before a delegation (`delegate` → DelegationPluginInvoker) -// runs. This is the manager-approval-then-apply pattern. -// -// The apl-core evaluator already unit-tests elicit sequencing and -// delegate dispatch in isolation. What this proves is the CPEX -// integration level: both real bridge invokers plugged into one -// `evaluate_route`, dispatching to real (fake-backed) plugins, in a -// single route. -// -// * Approval DENIED → route halts at the gate; the delegate plugin -// is never called (no token minted). -// * Approval APPROVED → gate passes; the delegate runs, mints a -// token, and `delegation.granted.*` is visible -// to the trailing post-check. - -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use chrono::{Duration, Utc}; - -use cpex_core::context::PluginContext; -use cpex_core::delegation::{DelegationPayload, TokenDelegateHook, HOOK_TOKEN_DELEGATE}; -use cpex_core::elicitation::{ - ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, - ElicitationStatusKind, HOOK_ELICIT, -}; -use cpex_core::extensions::raw_credentials::{ - RawCredentialsExtension, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole, -}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; - -use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, PdpCall, PdpDecision, PdpDialect, - PdpError, PdpResolver, RoutePayload, -}; -use apl_cpex::{ - CmfPluginInvoker, DelegationPluginInvoker, DispatchCache, ElicitationPluginInvoker, - MemorySessionStore, SessionStore, -}; - -// --------------------------------------------------------------------- -// Fake elicit plugin — approves or denies on `check`, per config. -// --------------------------------------------------------------------- - -struct FakeApprover { - cfg: PluginConfig, - check_outcome: ElicitationOutcomeKind, -} - -#[async_trait] -impl Plugin for FakeApprover { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for FakeApprover { - async fn handle( - &self, - payload: &ElicitationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let mut out = payload.clone(); - match payload.operation() { - ElicitationOp::Dispatch => { - out.id = Some("elic-1".to_string()); - out.status = Some(ElicitationStatusKind::Pending); - out.approver = Some(payload.from().to_string()); - out.intent_id = Some("intent-1".to_string()); - out.expires_at = Some("2099-12-31T00:00:00Z".to_string()); - }, - // Resolve immediately (approved or denied) so a single - // evaluate pass reaches the delegate step or halts at it. - ElicitationOp::Check => { - out.status = Some(ElicitationStatusKind::Resolved); - out.outcome = Some(self.check_outcome); - }, - ElicitationOp::Validate => { - out.valid = Some(true); - out.approver = Some("manager@corp.com".to_string()); - out.intent_id = Some("intent-1".to_string()); - }, - } - PluginResult::modify_payload(out) - } -} - -fn approver_cfg() -> PluginConfig { - PluginConfig { - name: "manager-approver".to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec![HOOK_ELICIT.to_string()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: std::collections::HashSet::new(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } -} - -// --------------------------------------------------------------------- -// Fake delegate plugin — records that it ran and mints a token. -// --------------------------------------------------------------------- - -struct RecordingDelegate { - cfg: PluginConfig, - /// Every target the plugin was asked to mint for. Empty ⇒ never ran. - ledger: Arc>>, -} - -#[async_trait] -impl Plugin for RecordingDelegate { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RecordingDelegate { - async fn handle( - &self, - payload: &DelegationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - self.ledger - .lock() - .unwrap() - .push(payload.target_name().to_string()); - - let token = RawDelegatedToken::new( - "fake.delegated.token", - "Authorization", - "workday-api", - vec!["read_compensation".to_string()], - Utc::now() + Duration::seconds(300), - ); - let mut updated = payload.clone(); - updated.delegated_token = Some(token); - PluginResult::modify_payload(updated) - } -} - -fn delegate_cfg() -> PluginConfig { - PluginConfig { - name: "workday-oauth".to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: std::collections::HashSet::new(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } -} - -// --------------------------------------------------------------------- -// Always-allow PDP stub — no scenario here exercises a PDP step. -// --------------------------------------------------------------------- - -struct AllowPdp; -#[async_trait] -impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }) - } -} - -// One route: approval gate, then delegation, then a granted post-check. -const ROUTE_YAML: &str = r#" -plugins: - - name: manager-approver - kind: test - hooks: [elicit] - - name: workday-oauth - kind: test - hooks: [token.delegate] -routes: - payroll_adjust: - pre_invocation: - - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" - - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - - "!delegation.granted: deny" -"#; - -/// Drive the whole route once with the approver resolving to -/// `outcome`. Returns `(route decision, number of delegate calls)`. -async fn run_route(outcome: ElicitationOutcomeKind) -> (Decision, usize) { - let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let mgr = Arc::new(PluginManager::default()); - mgr.register_handler::( - Arc::new(FakeApprover { - cfg: approver_cfg(), - check_outcome: outcome, - }), - approver_cfg(), - ) - .expect("register approver"); - mgr.register_handler::( - Arc::new(RecordingDelegate { - cfg: delegate_cfg(), - ledger: Arc::clone(&ledger), - }), - delegate_cfg(), - ) - .expect("register delegate"); - mgr.initialize().await.expect("initialize"); - - let cfg = compile_config(ROUTE_YAML).expect("compile route YAML"); - let route = cfg.routes.get("payroll_adjust").expect("route present"); - let cache = Arc::new(DispatchCache::new()); - let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; - - // A User inbound token so the (default user-subject) delegation has - // a bearer to carry. - let mut raw = RawCredentialsExtension::default(); - raw.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new("eyJ.fake.user", "Authorization", TokenKind::Jwt), - ); - let extensions = Extensions { - raw_credentials: Some(Arc::new(raw)), - ..Default::default() - }; - - let session_store: Arc = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&mgr), - extensions, - cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text( - cpex_core::cmf::enums::Role::User, - "adjust payroll", - ), - }, - Arc::clone(&plan), - Arc::clone(&session_store), - ) - .await - .expect("for_request"), - ); - - // Both bridge invokers share the request's extensions + plan. - let delegations = Arc::new(DelegationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - let elicitations = Arc::new(ElicitationPluginInvoker::new( - Arc::clone(&mgr), - invoker.extensions_arc(), - invoker.plan_arc(), - )); - - let mut bag = apl_cmf::BagBuilder::new() - .with_extensions(&invoker.current_extensions().await) - .with_route_key(&route.route_key) - .build(); - // Seed the two attributes the gate reads: `from` must resolve to an - // identity, and the approval `scope` predicate is checked against - // the live args. - bag.set("claim.manager", "manager@corp.com"); - bag.set("args.amount", 1000_i64); - - let mut payload = RoutePayload::new(serde_json::Value::Null); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(delegations.clone() as Arc), - &(elicitations.clone() as Arc), - ) - .await; - - let calls = ledger.lock().unwrap().len(); - (decision.decision, calls) -} - -/// Approval denied ⇒ the gate halts the route and the delegation never -/// runs. This is the property that makes "approval gates delegation" -/// real: a denied approval must stop the token from ever being minted. -#[tokio::test] -async fn denied_approval_halts_before_delegation() { - let (decision, delegate_calls) = run_route(ElicitationOutcomeKind::Denied).await; - - assert!( - matches!(decision, Decision::Deny { .. }), - "denied approval must halt the route; got {decision:?}", - ); - assert_eq!( - delegate_calls, 0, - "delegation must NOT run when approval was denied", - ); -} - -/// Approval granted ⇒ the gate passes, the delegation runs and mints a -/// token, and the trailing `!delegation.granted: deny` post-check is -/// satisfied so the route allows. -#[tokio::test] -async fn approved_gate_lets_delegation_run() { - let (decision, delegate_calls) = run_route(ElicitationOutcomeKind::Approved).await; - - assert_eq!( - decision, - Decision::Allow, - "approved gate + successful delegation should allow; got {decision:?}", - ); - assert_eq!( - delegate_calls, 1, - "delegation must run exactly once after approval", - ); -} diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs deleted file mode 100644 index 3a676049..00000000 --- a/crates/apl-cpex/tests/end_to_end_route.rs +++ /dev/null @@ -1,1090 +0,0 @@ -// Location: ./crates/apl-cpex/tests/end_to_end_route.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end integration: APL YAML config → `compile_config` → -// `evaluate_route` → `CmfPluginInvoker::invoke` → typed CPEX dispatch -// via `invoke_named::` → real plugin handler → result mapped -// back through apl-core's `Decision`. -// -// This is the load-bearing test for v0 — it proves apl-core + -// apl-cpex + cpex-core compose through their public surfaces. -// -// The earlier `cmf_invoker_dispatch.rs` exercised the invoker -// directly. This file goes one layer up: the host writes a tiny APL -// route YAML, the evaluator drives the route, and the invoker is the -// only thing that translates plugin-named steps into CMF hook calls. - -use std::sync::Arc; - -use async_trait::async_trait; -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError as CoreError, PluginViolation}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use apl_core::pipeline::TaintScope; -use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, NoopDelegationInvoker, - NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, -}; - -use apl_cpex::{ - register_apl, AplOptions, CmfPluginInvoker, DispatchCache, MemorySessionStore, SessionStore, - SessionStoreError, -}; - -// Build Extensions carrying a client/upstream session id (tier-0) AND an -// authenticated subject, and return the session-store key the resolver -// derives for them. Tier-0 session ids are subject-bound, so these tests must key the store by the resolved value rather -// than the raw string they supply. -fn session_ext_and_key(session_id: &str, subject_id: &str) -> (Extensions, String) { - let mut agent = cpex_core::extensions::AgentExtension::default(); - agent.session_id = Some(session_id.into()); - let mut subject = cpex_core::extensions::SubjectExtension::default(); - subject.id = Some(subject_id.into()); - let ext = Extensions { - agent: Some(Arc::new(agent)), - security: Some(Arc::new(cpex_core::extensions::SecurityExtension { - subject: Some(subject), - ..Default::default() - })), - ..Default::default() - }; - let key = apl_cpex::session_resolver::resolve_session(&ext) - .expect("subject-bound session resolves") - .0; - (ext, key) -} - -// --------------------------------------------------------------------- -// Stub PDP — apl-core requires `&dyn PdpResolver`, but no scenario in -// this file exercises a PDP step, so an always-allow stub is enough. -// --------------------------------------------------------------------- - -struct AllowPdp; - -#[async_trait] -impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { - PdpDialect::Cedar - } - async fn evaluate( - &self, - _call: &PdpCall, - _bag: &AttributeBag, - ) -> Result { - Ok(PdpDecision { - decision: Decision::Allow, - diagnostics: vec![], - }) - } -} - -// --------------------------------------------------------------------- -// Test CMF plugins — minimal handlers registered on `cmf.tool_pre_invoke` -// (the hook `CmfPluginInvoker` dispatches `PluginInvocation::Step` to -// by default). Duplicated from `cmf_invoker_dispatch.rs` because cargo -// test files don't share modules without a `tests/common/` layout, and -// the fixtures are tiny enough that mild duplication beats the layout -// churn for v0. -// --------------------------------------------------------------------- - -struct AllowPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for AllowPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AllowPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } -} - -struct AllowPluginFactory; -impl PluginFactory for AllowPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -struct DenyPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for DenyPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for DenyPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(PluginViolation::new( - "policy.forbidden", - "scope-gate fixture denied this call", - )) - } -} - -struct DenyPluginFactory; -impl PluginFactory for DenyPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -// --------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------- - -async fn manager_with(kind: &str, factory: Box) -> Arc { - let mgr = PluginManager::default(); - mgr.register_factory(kind, factory); - let yaml = format!("plugins:\n - name: {0}\n kind: {0}\n", kind); - let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); - mgr.load_config(cfg).expect("load_config"); - mgr.initialize().await.expect("initialize"); - Arc::new(mgr) -} - -fn empty_payload() -> RoutePayload { - RoutePayload::new(serde_json::json!({})) -} - -fn cmf_payload() -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, "irrelevant for v0 step-only test"), - } -} - -// --------------------------------------------------------------------- -// Scenarios -// --------------------------------------------------------------------- - -/// Route with one policy step `plugin(scope-gate)`. The CPEX plugin -/// registered under that name returns `allow()`. `evaluate_route` must -/// therefore return `Decision::Allow` end-to-end. The hook name is now -/// resolved from the root `plugins:` block in YAML — no hardcoded -/// defaults on the invoker. -#[tokio::test] -async fn route_with_allow_plugin_evaluates_allow() { - const YAML: &str = r#" -plugins: - - name: scope-gate - kind: scope-gate - hooks: [cmf.tool_pre_invoke] -routes: - get_weather: - pre_invocation: - - "plugin(scope-gate)" -"#; - - let mgr = manager_with("scope-gate", Box::new(AllowPluginFactory)).await; - let cfg = compile_config(YAML).expect("compile_config"); - let route = cfg.routes.get("get_weather").expect("route present"); - let cache = DispatchCache::new(); - let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; - let invoker = Arc::new( - CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - cmf_payload(), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"), - ); - - let mut bag = AttributeBag::new(); - let mut payload = empty_payload(); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(Arc::new(NoopDelegationInvoker) as Arc), - &(Arc::new(NoopElicitationInvoker) as Arc), - ) - .await; - - assert_eq!(decision.decision, Decision::Allow); - assert!(decision.taints.is_empty()); - assert!(!decision.args_modified); - assert!(!decision.result_modified); -} - -/// Same route shape, but the CPEX plugin denies. `evaluate_route` must -/// surface that as `Decision::Deny` with the violation reason + code -/// flowed through `CmfPluginInvoker`. -#[tokio::test] -async fn route_with_deny_plugin_surfaces_violation_through_route_decision() { - const YAML: &str = r#" -plugins: - - name: scope-gate - kind: scope-gate - hooks: [cmf.tool_pre_invoke] -routes: - get_weather: - pre_invocation: - - "plugin(scope-gate)" -"#; - - let mgr = manager_with("scope-gate", Box::new(DenyPluginFactory)).await; - let cfg = compile_config(YAML).expect("compile_config"); - let route = cfg.routes.get("get_weather").expect("route present"); - let cache = DispatchCache::new(); - let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; - let invoker = Arc::new( - CmfPluginInvoker::for_request( - mgr, - Extensions::default(), - cmf_payload(), - plan, - Arc::new(MemorySessionStore::new()), - ) - .await - .expect("for_request"), - ); - - let mut bag = AttributeBag::new(); - let mut payload = empty_payload(); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(Arc::new(NoopDelegationInvoker) as Arc), - &(Arc::new(NoopElicitationInvoker) as Arc), - ) - .await; - - match decision.decision { - Decision::Deny { - reason, - rule_source, - } => { - assert_eq!( - reason.as_deref(), - Some("scope-gate fixture denied this call"), - "violation reason should flow back through CmfPluginInvoker → \ - PluginOutcome → evaluate_steps → RouteDecision" - ); - assert_eq!(rule_source, "policy.forbidden"); - }, - other => panic!("expected Decision::Deny, got {:?}", other), - } -} - -// --------------------------------------------------------------------- -// Taint extraction — plugin adds a security label via cow_copy + -// modify_extensions; invoker diffs labels, surfaces the new ones as -// TaintEvent in PluginOutcome.taints. evaluate_steps accumulates them -// into RouteDecision.taints. SessionStore receives the new label via -// persist_session. -// --------------------------------------------------------------------- - -struct TaintingPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for TaintingPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for TaintingPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // cow_copy gives an OwnedExtensions handle inheriting any write - // tokens the executor set up (append_labels grants the - // labels_write_token automatically because the registration - // declares the capability). - let mut owned = extensions.cow_copy(); - let security = owned.security.get_or_insert_with(Default::default); - security.add_label("PII"); - PluginResult::modify_extensions(owned) - } -} - -struct TaintingPluginFactory; -impl PluginFactory for TaintingPluginFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(TaintingPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Build a manager whose registered plugin has `append_labels` capability, -/// without which the executor would refuse the modified labels on the way -/// out (label monotonicity is enforced under the write-token system). -async fn tainting_manager() -> Arc { - let mgr = PluginManager::default(); - mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); - let yaml = "plugins:\n - name: tagger\n kind: tagger\n capabilities: [append_labels, read_labels]\n"; - let cfg = cpex_core::config::parse_config(yaml).expect("parse_config"); - mgr.load_config(cfg).expect("load_config"); - mgr.initialize().await.expect("initialize"); - Arc::new(mgr) -} - -#[tokio::test] -async fn route_plugin_emitting_label_surfaces_taint_and_persists_to_session() { - const YAML: &str = r#" -plugins: - - name: tagger - kind: tagger - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] -routes: - classify: - pre_invocation: - - "plugin(tagger)" -"#; - - let mgr = tainting_manager().await; - let cfg = compile_config(YAML).expect("compile_config"); - let route = cfg.routes.get("classify").expect("route present"); - let cache = DispatchCache::new(); - let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; - - // Session id pinned via tier-0 (agent.session_id) plus a subject, so the - // store key is the deterministic subject-bound hash the resolver derives. - let (extensions, session_key) = session_ext_and_key("sess-taint-test", "alice"); - - let session_store = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) - .await - .expect("for_request"), - ); - - let mut bag = AttributeBag::new(); - let mut payload = empty_payload(); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(Arc::new(NoopDelegationInvoker) as Arc), - &(Arc::new(NoopElicitationInvoker) as Arc), - ) - .await; - - // Decision flows through allow (plugin's modify_extensions doesn't - // halt the pipeline). - assert_eq!(decision.decision, Decision::Allow); - - // The label-emit traveled the full path: - // plugin.handle → modify_extensions → - // PipelineResult.modified_extensions → - // CmfPluginInvoker.invoke (label diff) → - // PluginOutcome.taints → - // evaluate_steps_inner accumulator → - // StepsEvaluation.taints → - // evaluate_route → RouteDecision.taints - assert_eq!( - decision.taints.len(), - 1, - "expected one taint event from tagger plugin" - ); - let event = &decision.taints[0]; - assert_eq!(event.label, "PII"); - assert_eq!(event.scopes, vec![TaintScope::Session]); - - // SessionStore persistence — host calls persist_session after route - // evaluation; new labels (vs the post-hydration snapshot) land in - // the store under the request's session_id. - invoker.persist_session().await.expect("persist_session"); - let stored = session_store - .load_labels(&session_key) - .await - .expect("load_labels"); - assert_eq!(stored, vec!["PII".to_string()]); -} - -#[tokio::test] -async fn session_store_hydrates_labels_at_request_start() { - // Pre-seed the session store with a label, then verify the invoker - // hydrates it into extensions.security.labels at for_request time - // (so the first plugin call sees the accumulated session state). - // Subject-bound session key: pre-seed under the resolved key. - let (extensions, session_key) = session_ext_and_key("sess-existing", "alice"); - let session_store = Arc::new(MemorySessionStore::new()); - session_store - .append_labels(&session_key, &["PRIOR".to_string()]) - .await - .expect("append_labels"); - - let mgr = tainting_manager().await; - let yaml = r#" -plugins: - - name: tagger - kind: tagger - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] -routes: - classify: - pre_invocation: - - "plugin(tagger)" -"#; - let cfg = compile_config(yaml).expect("compile_config"); - let route = cfg.routes.get("classify").unwrap(); - let plan = DispatchCache::new() - .get_or_build(route, &cfg.plugins, &mgr) - .await; - - let invoker = Arc::new( - CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) - .await - .expect("for_request"), - ); - - // Hydrated labels should be observable on the invoker's extensions. - let snapshot = invoker.current_extensions().await; - let security = snapshot - .security - .expect("hydration creates security extension"); - assert!( - security.has_label("PRIOR"), - "hydration should pull PRIOR from session store" - ); - - // Now drive a route — tagger adds PII. After persist, the store has - // both PRIOR (from hydration) and PII (newly emitted). - let mut bag = AttributeBag::new(); - let mut payload = empty_payload(); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(Arc::new(NoopDelegationInvoker) as Arc), - &(Arc::new(NoopElicitationInvoker) as Arc), - ) - .await; - assert_eq!(decision.decision, Decision::Allow); - - // Only the NEW label (PII) shows up as a taint — PRIOR was already - // present before the plugin ran, so it's not a fresh emission. - assert_eq!(decision.taints.len(), 1); - assert_eq!(decision.taints[0].label, "PII"); - - invoker.persist_session().await.expect("persist_session"); - let mut stored = session_store - .load_labels(&session_key) - .await - .expect("load_labels"); - stored.sort(); - assert_eq!(stored, vec!["PII".to_string(), "PRIOR".to_string()]); -} - -/// Proof: an APL `taint(audit, session)` step lands the -/// label in `security.labels` (via `apply_session_taints`) AND the -/// SessionStore (via `persist_session`). No plugin is involved — the -/// taint comes from the YAML, not from any handler's modify_extensions. -/// This is the load-bearing end-to-end test for the -/// "policy with side-effects" pitch: writing `taint(...)` in YAML -/// actually causes the session to be permanently labelled. -#[tokio::test] -async fn apl_taint_step_lands_in_security_labels_and_persists() { - const YAML: &str = r#" -routes: - classify: - pre_invocation: - - "taint(audit, session)" -"#; - - let mgr = manager_with("noop", Box::new(AllowPluginFactory)).await; - let cfg = compile_config(YAML).expect("compile_config"); - let route = cfg.routes.get("classify").expect("route present"); - let plan = DispatchCache::new() - .get_or_build(route, &cfg.plugins, &mgr) - .await; - - let (extensions, session_key) = session_ext_and_key("sess-apl-taint", "alice"); - - let session_store = Arc::new(MemorySessionStore::new()); - let invoker = Arc::new( - CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) - .await - .expect("for_request"), - ); - - let mut bag = AttributeBag::new(); - let mut payload = empty_payload(); - let decision = evaluate_route( - route, - &mut bag, - &mut payload, - &(Arc::new(AllowPdp) as Arc), - &(invoker.clone() as Arc), - &(Arc::new(NoopDelegationInvoker) as Arc), - &(Arc::new(NoopElicitationInvoker) as Arc), - ) - .await; - assert_eq!(decision.decision, Decision::Allow); - - // Evaluator surfaced the YAML taint into the decision. - assert_eq!( - decision.taints.len(), - 1, - "expected one taint from `taint(...)` step" - ); - assert_eq!(decision.taints[0].label, "audit"); - assert!(decision.taints[0].scopes.contains(&TaintScope::Session)); - - // This is the new wiring: drain Session-scoped taints into - // `security.labels` exactly as `AplRouteHandler::invoke` does. - invoker.apply_session_taints(&decision.taints).await; - - let snapshot = invoker.current_extensions().await; - let security = snapshot - .security - .as_ref() - .expect("apply_session_taints should have created the security ext"); - assert!( - security.has_label("audit"), - "session-scoped taint should land in security.labels", - ); - - // And `persist_session` should pick up the label via the diff - // against `initial_labels` (which was empty here). - invoker.persist_session().await.expect("persist_session"); - let stored = session_store - .load_labels(&session_key) - .await - .expect("load_labels"); - assert_eq!(stored, vec!["audit".to_string()]); -} - -// --------------------------------------------------------------------- -// Fail-closed semantics. -// -// A distributed SessionStore can fail. These tests use an erroring -// test-double to prove the request fails *closed* — a store error -// becomes a Deny, never a silent "no labels" Allow. -// --------------------------------------------------------------------- - -/// Test-double store that fails load and/or append on demand. -struct ErrorSessionStore { - fail_load: bool, - fail_append: bool, -} - -#[async_trait] -impl SessionStore for ErrorSessionStore { - async fn load_labels(&self, _session_id: &str) -> Result, SessionStoreError> { - if self.fail_load { - Err(SessionStoreError::Backend("simulated load failure".into())) - } else { - Ok(Vec::new()) - } - } - - async fn append_labels( - &self, - _session_id: &str, - _labels: &[String], - ) -> Result<(), SessionStoreError> { - if self.fail_append { - Err(SessionStoreError::Backend( - "simulated append failure".into(), - )) - } else { - Ok(()) - } - } -} - -// Tagger route wired through `register_apl` so requests flow through the -// real `AplRouteHandler::invoke` path (where the fail-closed logic lives). -const TAGGER_ROUTE_YAML: &str = r#" -plugins: - - name: tagger - kind: tagger - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(tagger)" -"#; - -// Route matching keys on the request's `meta` (entity type + name), so a -// request must carry tool meta for the `tool: get_weather` handler to fire. -fn set_tool_meta(ext: &mut Extensions, tool: &str) { - let mut meta = cpex_core::extensions::MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(tool.to_string()); - ext.meta = Some(Arc::new(meta)); -} - -async fn tagger_manager_with_store(store: Arc) -> Arc { - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: store, - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(TAGGER_ROUTE_YAML) - .expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -/// A load failure during hydration fails the request closed *before* -/// any decision, with the distinguished `session.load_failed` violation. -#[tokio::test] -async fn load_failure_fails_request_closed() { - let store: Arc = Arc::new(ErrorSessionStore { - fail_load: true, - fail_append: false, - }); - let mgr = tagger_manager_with_store(store).await; - let (mut ext, _key) = session_ext_and_key("sess-load-fail", "alice"); - set_tool_meta(&mut ext, "get_weather"); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "a load failure must fail the request closed (Deny)" - ); - assert_eq!( - result.violation.as_ref().map(|v| v.code.as_str()), - Some("session.load_failed"), - ); -} - -/// An append failure after the (Allow) decision flips the request to -/// Deny with the distinguished `session.persist_failed` violation — the -/// accumulated taint is never silently dropped. -#[tokio::test] -async fn append_failure_fails_request_closed() { - let store: Arc = Arc::new(ErrorSessionStore { - fail_load: false, - fail_append: true, - }); - let mgr = tagger_manager_with_store(store).await; - let (mut ext, _key) = session_ext_and_key("sess-append-fail", "alice"); - set_tool_meta(&mut ext, "get_weather"); - - // The tagger emits a session-scoped label, so persist_session has a - // new label to append — which the store rejects. - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "an append failure must flip the Allow decision to Deny" - ); - assert_eq!( - result.violation.as_ref().map(|v| v.code.as_str()), - Some("session.persist_failed"), - ); -} - -// Same tagger route as `TAGGER_ROUTE_YAML`, but with a route-level -// `response:` block — proves the fail-closed session-store denials -// (`session.load_failed` / `session.persist_failed`) decorate their -// violation with the route's custom denyWith too, not just an ordinary -// `Decision::Deny`. -const TAGGER_ROUTE_WITH_RESPONSE_YAML: &str = r#" -plugins: - - name: tagger - kind: tagger - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(tagger)" - response: - status: 503 - body: "session unavailable" -"#; - -async fn tagger_manager_with_store_and_yaml( - store: Arc, - yaml: &str, -) -> Arc { - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: store, - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -/// A `session.load_failed` denial still carries the route's custom -/// `response:` (denyWith) on its `details` map — the fix that closed prior -/// review gap #3 must hold for the load-failure fail-closed path, not just -/// `Decision::Deny`. -#[tokio::test] -async fn load_failure_carries_route_response() { - let store: Arc = Arc::new(ErrorSessionStore { - fail_load: true, - fail_append: false, - }); - let mgr = tagger_manager_with_store_and_yaml(store, TAGGER_ROUTE_WITH_RESPONSE_YAML).await; - let (mut ext, _key) = session_ext_and_key("sess-load-fail-resp", "alice"); - set_tool_meta(&mut ext, "get_weather"); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - - assert!(!result.continue_processing); - let violation = result - .violation - .expect("load failure must surface a violation"); - assert_eq!(violation.code, "session.load_failed"); - assert_eq!( - violation - .details - .get(apl_cmf::constants::DETAIL_HTTP_STATUS), - Some(&serde_json::json!(503)), - "load_failed denial must carry the route's custom response status" - ); -} - -/// A `session.persist_failed` denial still carries the route's -/// custom `response:` (denyWith) on its `details` map. -#[tokio::test] -async fn persist_failure_carries_route_response() { - let store: Arc = Arc::new(ErrorSessionStore { - fail_load: false, - fail_append: true, - }); - let mgr = tagger_manager_with_store_and_yaml(store, TAGGER_ROUTE_WITH_RESPONSE_YAML).await; - let (mut ext, _key) = session_ext_and_key("sess-append-fail-resp", "alice"); - set_tool_meta(&mut ext, "get_weather"); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - - assert!(!result.continue_processing); - let violation = result - .violation - .expect("append failure must flip to a Deny with a violation"); - assert_eq!(violation.code, "session.persist_failed"); - assert_eq!( - violation - .details - .get(apl_cmf::constants::DETAIL_HTTP_STATUS), - Some(&serde_json::json!(503)), - "persist_failed denial must carry the route's custom response status" - ); -} - -/// When the policy already Denies AND the append -/// fails, the original policy violation is preserved (not overwritten by -/// `session.persist_failed`) — the request is already denied, so the -/// append failure surfaces only as the alarm. -#[tokio::test] -async fn deny_plus_append_failure_preserves_policy_violation() { - const YAML: &str = r#" -plugins: - - name: tagger - kind: tagger - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] - - name: scope-gate - kind: scope-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(tagger)" - - "plugin(scope-gate)" -"#; - let store: Arc = Arc::new(ErrorSessionStore { - fail_load: false, - fail_append: true, - }); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); - mgr.register_factory("scope-gate", Box::new(DenyPluginFactory)); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: store, - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let (mut ext, _key) = session_ext_and_key("sess-deny-append", "alice"); - set_tool_meta(&mut ext, "get_weather"); - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - - assert!( - !result.continue_processing, - "policy denied → request blocked" - ); - // The original policy violation is preserved; the append failure does - // NOT overwrite it with session.persist_failed. - assert_eq!( - result.violation.as_ref().map(|v| v.code.as_str()), - Some("policy.forbidden"), - "Deny+append-err must keep the policy violation, not session.persist_failed" - ); -} - -/// Sessionless/anonymous traffic carries no session_id, so it never -/// touches the store and is unaffected by a store outage. -#[tokio::test] -async fn sessionless_request_unaffected_by_store_failure() { - let store: Arc = Arc::new(ErrorSessionStore { - fail_load: true, - fail_append: true, - }); - let mgr = tagger_manager_with_store(store).await; - - // Tool meta so the route handler fires, but no session/subject — so - // the request resolves to no session id and never touches the store. - let mut ext = Extensions::default(); - set_tool_meta(&mut ext, "get_weather"); - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - - assert!( - result.continue_processing, - "sessionless traffic should not be denied by a store outage: {:?}", - result.violation - ); -} - -// --------------------------------------------------------------------- -// Config-driven backend selection. -// --------------------------------------------------------------------- - -/// Records every load/append so a test can prove which store was active. -#[derive(Default)] -struct RecordingSessionStore { - loads: std::sync::Mutex>, - appends: std::sync::Mutex)>>, -} - -#[async_trait] -impl SessionStore for RecordingSessionStore { - async fn load_labels(&self, session_id: &str) -> Result, SessionStoreError> { - self.loads.lock().unwrap().push(session_id.to_string()); - Ok(Vec::new()) - } - async fn append_labels( - &self, - session_id: &str, - labels: &[String], - ) -> Result<(), SessionStoreError> { - self.appends - .lock() - .unwrap() - .push((session_id.to_string(), labels.to_vec())); - Ok(()) - } -} - -/// Factory that hands back a specific recording store so the test can -/// inspect it after the config walk selected it. -struct RecordingFactory { - store: Arc, -} - -impl apl_cpex::SessionStoreFactory for RecordingFactory { - fn kind(&self) -> &str { - "recording-fake" - } - fn build( - &self, - _config: &serde_yaml::Value, - ) -> Result, Box> { - Ok(self.store.clone()) - } -} - -/// A `global.apl.session_store { kind: recording-fake }` block makes -/// the factory-built store the active one — the default `MemorySessionStore` -/// passed to `AplOptions` is overridden by config. -#[tokio::test] -async fn config_selects_session_store_via_factory() { - const YAML: &str = r#" -plugins: - - name: tagger - kind: tagger - hooks: [cmf.tool_pre_invoke] - capabilities: [append_labels, read_labels] -global: - apl: - session_store: - kind: recording-fake -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(tagger)" -"#; - - let recording = Arc::new(RecordingSessionStore::default()); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - // Default store that config should override: - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: vec![Arc::new(RecordingFactory { - store: Arc::clone(&recording), - })], - base_capabilities: None, - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let (mut ext, _key) = session_ext_and_key("sess-cfg", "alice"); - set_tool_meta(&mut ext, "get_weather"); - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) - .await; - assert!(result.continue_processing, "tagger route allows"); - - // The config-selected recording store — NOT the default memory store — - // received the hydration load and the taint append. - assert!( - !recording.loads.lock().unwrap().is_empty(), - "config-selected store should receive the hydration load" - ); - assert_eq!( - recording.appends.lock().unwrap().len(), - 1, - "config-selected store should receive the taint append" - ); -} - -/// Unknown `kind` in a session_store block fails config load loudly. -#[tokio::test] -async fn unknown_session_store_kind_fails_config_load() { - const YAML: &str = r#" -global: - apl: - session_store: - kind: nonexistent-backend -"#; - let mgr = Arc::new(PluginManager::default()); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - let err = mgr - .load_config_yaml(YAML) - .expect_err("unknown kind must fail load"); - assert!( - format!("{err}").contains("nonexistent-backend"), - "error should name the unresolved kind: {err}" - ); -} diff --git a/crates/apl-cpex/tests/global_http_authz.rs b/crates/apl-cpex/tests/global_http_authz.rs deleted file mode 100644 index 970d21f3..00000000 --- a/crates/apl-cpex/tests/global_http_authz.rs +++ /dev/null @@ -1,287 +0,0 @@ -// Location: ./crates/apl-cpex/tests/global_http_authz.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// End-to-end: a `global` APL policy is evaluated for a generic -// (non-MCP/A2A) HTTP request that carries no entity. The visitor installs -// a catch-all handler under (ENTITY_HTTP, ENTITY_NAME_GLOBAL, -// HOOK_CMF_HTTP_REQUEST); the host fires that hook with `meta` set to the -// reserved coordinates and an `http` extension carrying the request line. -// This is the entity-less authorization path for an L7 proxy. It also exercises -// (http.method in the bag) and custom denyWith via the route -// `response:` block surfaced on the violation details. - -use std::sync::Arc; - -use cpex_core::cmf::constants::{ENTITY_HTTP, ENTITY_NAME_GLOBAL, HOOK_CMF_HTTP_REQUEST}; -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::extensions::{Extensions, HttpExtension, MetaExtension}; -use cpex_core::manager::PluginManager; - -use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS}; -use apl_cpex::{register_apl, AplOptions}; - -async fn manager_with(yaml: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - register_apl(&mgr, AplOptions::in_process()); - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -/// A generic-HTTP request: reserved entity coordinates + an `http` -/// extension carrying the request method. -fn http_request(method: &str) -> Extensions { - let mut meta = MetaExtension::default(); - meta.entity_type = Some(ENTITY_HTTP.to_string()); - meta.entity_name = Some(ENTITY_NAME_GLOBAL.to_string()); - let http = HttpExtension { - method: Some(method.to_string()), - ..Default::default() - }; - Extensions { - meta: Some(Arc::new(meta)), - http: Some(Arc::new(http)), - ..Default::default() - } -} - -fn payload() -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, "hi"), - } -} - -/// An MCP tool-call request: `meta` naming a `tool` entity, no `http` ext. -fn tool_request(name: &str) -> Extensions { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - Extensions { - meta: Some(Arc::new(meta)), - ..Default::default() - } -} - -/// An MCP tool-call request that also carries an `http` extension — the -/// shape a host produces when it enriches an entity request with the HTTP -/// request line so one policy can combine `http.*` and entity attributes. -fn tool_request_with_http(name: &str, method: &str) -> Extensions { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - let http = HttpExtension { - method: Some(method.to_string()), - ..Default::default() - }; - Extensions { - meta: Some(Arc::new(meta)), - http: Some(Arc::new(http)), - ..Default::default() - } -} - -// APL predicate:action form: deny when the method is not GET. (Comparisons -// use this form; `require(...)` is truthiness-only.) -const GET_ONLY: &str = r#" -plugin_settings: - routing_enabled: true -global: - apl: - pre_invocation: - - "http.method != 'GET': deny" -"#; - -#[tokio::test] -async fn global_policy_allows_matching_http_request() { - let mgr = manager_with(GET_ONLY).await; - let (res, _bg) = mgr - .invoke_named::(HOOK_CMF_HTTP_REQUEST, payload(), http_request("GET"), None) - .await; - assert!( - res.continue_processing, - "GET must be allowed by the global policy; violation = {:?}", - res.violation - ); -} - -#[tokio::test] -async fn global_policy_denies_nonmatching_http_request() { - let mgr = manager_with(GET_ONLY).await; - let (res, _bg) = mgr - .invoke_named::(HOOK_CMF_HTTP_REQUEST, payload(), http_request("POST"), None) - .await; - assert!( - !res.continue_processing, - "POST must be denied by the global policy" - ); -} - -/// A route-level `response:` block (transpiled `denyWith`) surfaces custom -/// status/body/headers on the violation `details` map when the global -/// policy denies. -#[tokio::test] -async fn global_policy_deny_carries_custom_response() { - const YAML: &str = r#" -plugin_settings: - routing_enabled: true -global: - apl: - pre_invocation: - - "http.method != 'GET': deny" - response: - status: 403 - body: "{\"error\":\"forbidden\"}" - headers: - X-Reason: "method-not-allowed" -"#; - let mgr = manager_with(YAML).await; - let (res, _bg) = mgr - .invoke_named::( - HOOK_CMF_HTTP_REQUEST, - payload(), - http_request("DELETE"), - None, - ) - .await; - assert!(!res.continue_processing, "DELETE must be denied"); - let v = res.violation.expect("deny must surface a violation"); - assert_eq!( - v.details.get(DETAIL_HTTP_STATUS), - Some(&serde_json::json!(403)) - ); - assert_eq!( - v.details.get(DETAIL_HTTP_BODY), - Some(&serde_json::json!("{\"error\":\"forbidden\"}")) - ); - assert_eq!( - v.details.get(DETAIL_HTTP_HEADERS), - Some(&serde_json::json!({ "X-Reason": "method-not-allowed" })) - ); -} - -/// A `global` `response:` (the entity-less HTTP catch-all denyWith) must NOT -/// be inherited by an entity route. A denied MCP tool call gets the plain -/// violation shape — no `http.*` details leaked from the global block. -#[tokio::test] -async fn global_response_does_not_leak_onto_entity_denial() { - const YAML: &str = r#" -plugin_settings: - routing_enabled: true -global: - apl: - pre_invocation: - - "require(authenticated)" - response: - status: 403 - body: "{\"error\":\"global\"}" -routes: - - tool: locked - apl: - pre_invocation: - - "require(authenticated)" -"#; - let mgr = manager_with(YAML).await; - let (res, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - tool_request("locked"), - None, - ) - .await; - assert!(!res.continue_processing, "tool policy must deny"); - let v = res.violation.expect("deny must surface a violation"); - assert!( - !v.details.contains_key(DETAIL_HTTP_STATUS) - && !v.details.contains_key(DETAIL_HTTP_BODY) - && !v.details.contains_key(DETAIL_HTTP_HEADERS), - "global response leaked onto entity denial: {:?}", - v.details - ); -} - -/// Entity routes are granted `read_headers`, so a tool route's rule can read -/// `http.*` from an enriched request. The route denies when the HTTP method -/// is GET: a GET tool request is denied only if `http.method` actually reached -/// the entity bag, which proves the capability grant. A POST request (the -/// predicate is false) passes, confirming the method is genuinely read. -#[tokio::test] -async fn entity_route_reads_http_attributes() { - const YAML: &str = r#" -plugin_settings: - routing_enabled: true -routes: - - tool: echo - apl: - pre_invocation: - - "http.method == 'GET': deny" -"#; - let mgr = manager_with(YAML).await; - - let (denied, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - tool_request_with_http("echo", "GET"), - None, - ) - .await; - assert!( - !denied.continue_processing, - "GET tool request must be denied — proves http.method reached the entity bag (read_headers granted)", - ); - - let (allowed, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - tool_request_with_http("echo", "POST"), - None, - ) - .await; - assert!( - allowed.continue_processing, - "POST tool request must pass (http.method == 'GET' is false); violation = {:?}", - allowed.violation, - ); -} - -/// A route-scoped `response:` still decorates that route's own denial — the -/// feature works per-route; only silent inheritance was removed. -#[tokio::test] -async fn route_scoped_response_still_decorates_entity_denial() { - const YAML: &str = r#" -plugin_settings: - routing_enabled: true -routes: - - tool: locked - apl: - pre_invocation: - - "require(authenticated)" - response: - status: 401 - body: "route" -"#; - let mgr = manager_with(YAML).await; - let (res, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload(), - tool_request("locked"), - None, - ) - .await; - assert!(!res.continue_processing, "tool policy must deny"); - let v = res.violation.expect("deny must surface a violation"); - assert_eq!( - v.details.get(DETAIL_HTTP_STATUS), - Some(&serde_json::json!(401)) - ); - assert_eq!( - v.details.get(DETAIL_HTTP_BODY), - Some(&serde_json::json!("route")) - ); -} diff --git a/crates/apl-cpex/tests/payload_mutation_propagation.rs b/crates/apl-cpex/tests/payload_mutation_propagation.rs deleted file mode 100644 index 97a7872d..00000000 --- a/crates/apl-cpex/tests/payload_mutation_propagation.rs +++ /dev/null @@ -1,1151 +0,0 @@ -// Location: ./crates/apl-cpex/tests/payload_mutation_propagation.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo -// -// A plugin mutation has to survive the whole way to the host, whichever -// part of the message it touched. These tests drive the real -// `AplRouteHandler` through `invoke_named::` and assert on the -// payload the host would forward. -// -// The failure these guard against is silent and fails open: a redactor -// rewrites `ToolResult.content`, reports success, and the host forwards -// the original secret anyway. Redaction and sanitisation plugins are -// exactly the ones that mutate non-text parts, so "only text mutations -// survive" is worst in the case that matters most. -// -// Text parts are left byte-identical on purpose in most fixtures below. -// Any check that infers "was this modified?" from message text passes -// them through unchanged, which is the bug. - -use std::sync::Arc; - -use async_trait::async_trait; -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, ToolCall, ToolResult}; -use cpex_core::context::PluginContext; -use cpex_core::error::PluginError as CoreError; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -// --------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------- - -/// Rewrites one content part and leaves the rest alone. Which part it -/// touches is chosen per instance so a single fixture covers several -/// `ContentPart` variants. -#[derive(Clone, Copy)] -enum Target { - /// Replace the whole tool result content. - ToolResultContent, - /// Replace one field inside an object-shaped tool result content, - /// leaving its siblings alone. - ToolResultField(&'static str), - /// Replace the `city` argument of a tool call. - ToolCallArguments, - /// Replace one named argument of a tool call. - ToolCallArgument(&'static str), - /// Replace a key inside an object-valued tool call argument. - NestedToolCallArgument(&'static str, &'static str), - Thinking, - Text, -} - -struct RewritePlugin { - cfg: PluginConfig, - target: Target, -} - -#[async_trait] -impl Plugin for RewritePlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RewritePlugin { - async fn handle( - &self, - payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let content: Vec = payload - .message - .content - .iter() - .map(|part| match (self.target, part) { - (Target::ToolResultContent, ContentPart::ToolResult { content }) => { - let mut next = content.clone(); - next.content = serde_json::Value::String("[REDACTED]".to_string()); - ContentPart::ToolResult { content: next } - }, - (Target::ToolResultField(field), ContentPart::ToolResult { content }) => { - let mut next = content.clone(); - if let Some(obj) = next.content.as_object_mut() { - obj.insert( - field.to_string(), - serde_json::Value::String("[REDACTED]".to_string()), - ); - } - ContentPart::ToolResult { content: next } - }, - (Target::ToolCallArguments, ContentPart::ToolCall { content }) => { - let mut next = content.clone(); - next.arguments.insert( - "city".to_string(), - serde_json::Value::String("[REDACTED]".to_string()), - ); - ContentPart::ToolCall { content: next } - }, - (Target::ToolCallArgument(arg), ContentPart::ToolCall { content }) => { - let mut next = content.clone(); - next.arguments.insert( - arg.to_string(), - serde_json::Value::String("[REDACTED]".to_string()), - ); - ContentPart::ToolCall { content: next } - }, - (Target::NestedToolCallArgument(arg, key), ContentPart::ToolCall { content }) => { - let mut next = content.clone(); - if let Some(obj) = next.arguments.get_mut(arg).and_then(|v| v.as_object_mut()) { - obj.insert( - key.to_string(), - serde_json::Value::String("[REDACTED]".to_string()), - ); - } - ContentPart::ToolCall { content: next } - }, - (Target::Thinking, ContentPart::Thinking { .. }) => ContentPart::Thinking { - text: "[REDACTED]".to_string(), - }, - (Target::Text, ContentPart::Text { .. }) => ContentPart::Text { - text: "[REDACTED]".to_string(), - }, - (_, other) => other.clone(), - }) - .collect(); - - PluginResult::modify_payload(MessagePayload { - message: Message { - schema_version: payload.message.schema_version.clone(), - role: payload.message.role, - content, - channel: payload.message.channel, - }, - }) - } -} - -struct RewriteFactory { - target: Target, - hook: &'static str, -} - -impl PluginFactory for RewriteFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(RewritePlugin { - cfg: config.clone(), - target: self.target, - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - self.hook, - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -/// Allows without returning a payload — the baseline for "nothing -/// changed, so nothing should be forwarded as modified". -struct NoopPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for NoopPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for NoopPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } -} - -/// Denies, so a route can mutate and then refuse in one request. -struct DenyPlugin { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for DenyPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for DenyPlugin { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(cpex_core::error::PluginViolation::new( - "policy.forbidden", - "test fixture denied this call", - )) - } -} - -struct DenyFactory; - -impl PluginFactory for DenyFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -struct NoopFactory { - hook: &'static str, -} - -impl PluginFactory for NoopFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(NoopPlugin { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - self.hook, - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -// --------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------- - -/// Wire one plugin behind an APL route on `get_weather`, with the route -/// phase and hook chosen by the caller. -async fn manager_with( - kind: &'static str, - factory: Box, - hook: &str, - phase: &str, -) -> Arc { - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory(kind, factory); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - let yaml = format!( - r#" -plugins: - - name: {kind} - kind: {kind} - hooks: [{hook}] -routes: - - tool: get_weather - apl: - {phase}: - - "plugin({kind})" -"# - ); - mgr.load_config_yaml(&yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -/// Wire one plugin behind a route that *also* runs a field pipeline, so -/// two editors touch the same content part in one request. -async fn manager_with_yaml( - kind: &'static str, - factory: Box, - yaml: &str, -) -> Arc { - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory(kind, factory); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -/// Routes match on the request's entity type + name, so a request needs -/// tool meta for the `tool: get_weather` handler to fire at all. -fn tool_meta() -> Extensions { - let mut meta = cpex_core::extensions::MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some("get_weather".to_string()); - Extensions { - meta: Some(Arc::new(meta)), - ..Default::default() - } -} - -fn tool_call_part(city: &str) -> ContentPart { - ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [("city".to_string(), serde_json::json!(city))] - .into_iter() - .collect(), - namespace: None, - }, - } -} - -fn tool_result_part(content: &str) -> ContentPart { - ContentPart::ToolResult { - content: ToolResult { - tool_call_id: "tc_001".to_string(), - tool_name: "get_weather".to_string(), - content: serde_json::Value::String(content.to_string()), - is_error: false, - }, - } -} - -fn payload_of(role: Role, parts: Vec) -> MessagePayload { - MessagePayload { - message: Message::with_content(role, parts), - } -} - -/// The payload the host would forward, downcast back to CMF. -fn forwarded(result: &cpex_core::executor::PipelineResult) -> MessagePayload { - result - .modified_payload - .as_ref() - .expect("an allowed pipeline always carries the final payload") - .as_any() - .downcast_ref::() - .expect("cmf hooks carry MessagePayload") - .clone() -} - -fn tool_result_of(payload: &MessagePayload) -> Option<&serde_json::Value> { - payload.message.content.iter().find_map(|part| match part { - ContentPart::ToolResult { content } => Some(&content.content), - _ => None, - }) -} - -fn tool_arg_of(payload: &MessagePayload, key: &str) -> Option { - payload.message.content.iter().find_map(|part| match part { - ContentPart::ToolCall { content } => content.arguments.get(key).cloned(), - _ => None, - }) -} - -// --------------------------------------------------------------------- -// Direct mutations reach the host, whichever part they touched -// --------------------------------------------------------------------- - -/// The reported failure: a redactor rewrites only `ToolResult.content`, -/// so the message's text is untouched. The redaction must reach the host. -#[tokio::test] -async fn tool_result_redaction_reaches_the_host() { - let mgr = manager_with( - "redactor", - Box::new(RewriteFactory { - target: Target::ToolResultContent, - hook: "cmf.tool_pre_invoke", - }), - "cmf.tool_pre_invoke", - "pre_invocation", - ) - .await; - - let payload = payload_of( - Role::Tool, - vec![ - ContentPart::Text { - text: "here is the result".to_string(), - }, - tool_result_part("sk-secret-value"), - ], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing, "route should allow"); - assert!( - result.payload_modified, - "the plugin returned a mutation, so the pipeline must report one" - ); - let out = forwarded(&result); - assert_eq!( - tool_result_of(&out), - Some(&serde_json::Value::String("[REDACTED]".to_string())), - "the host must receive the redacted tool result, not the original secret" - ); - assert_eq!( - out.message.get_text_content(), - "here is the result", - "fixture sanity: text is untouched, so text comparison sees no change" - ); -} - -/// Same shape, on the arguments of an outgoing tool call. -#[tokio::test] -async fn tool_call_argument_rewrite_reaches_the_host() { - let mgr = manager_with( - "arg-redactor", - Box::new(RewriteFactory { - target: Target::ToolCallArguments, - hook: "cmf.tool_pre_invoke", - }), - "cmf.tool_pre_invoke", - "pre_invocation", - ) - .await; - - let payload = payload_of(Role::User, vec![tool_call_part("London")]); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - assert_eq!( - tool_arg_of(&forwarded(&result), "city"), - Some(serde_json::json!("[REDACTED]")) - ); -} - -/// A thinking block carries no text part at all, so this is the cheapest -/// proof the fix is variant-agnostic rather than a tool-result special case. -#[tokio::test] -async fn thinking_rewrite_reaches_the_host() { - let mgr = manager_with( - "thought-redactor", - Box::new(RewriteFactory { - target: Target::Thinking, - hook: "cmf.tool_pre_invoke", - }), - "cmf.tool_pre_invoke", - "pre_invocation", - ) - .await; - - let payload = payload_of( - Role::Assistant, - vec![ContentPart::Thinking { - text: "the user's SSN is 123-45-6789".to_string(), - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - assert_eq!( - forwarded(&result).message.get_thinking_content().as_deref(), - Some("[REDACTED]") - ); -} - -/// The case that always worked. Kept so a future change can't fix the -/// others by breaking this one. -#[tokio::test] -async fn text_rewrite_still_reaches_the_host() { - let mgr = manager_with( - "text-redactor", - Box::new(RewriteFactory { - target: Target::Text, - hook: "cmf.tool_pre_invoke", - }), - "cmf.tool_pre_invoke", - "pre_invocation", - ) - .await; - - let payload = payload_of( - Role::User, - vec![ContentPart::Text { - text: "my card is 4111 1111 1111 1111".to_string(), - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - assert_eq!(forwarded(&result).message.get_text_content(), "[REDACTED]"); -} - -/// Post phase carries its own handler instance, so it needs its own proof. -#[tokio::test] -async fn tool_result_redaction_reaches_the_host_in_post_phase() { - let mgr = manager_with( - "redactor", - Box::new(RewriteFactory { - target: Target::ToolResultContent, - hook: "cmf.tool_post_invoke", - }), - "cmf.tool_post_invoke", - "post_invocation", - ) - .await; - - let payload = payload_of(Role::Tool, vec![tool_result_part("sk-secret-value")]); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_post_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - assert_eq!( - tool_result_of(&forwarded(&result)), - Some(&serde_json::Value::String("[REDACTED]".to_string())) - ); -} - -// --------------------------------------------------------------------- -// A pipeline edit and a plugin edit in the same request -// -// Both write to the same content part: the pipeline through -// `route_payload.args` / `.result`, the plugin through the payload -// itself. Folding the pipeline's view back in has to be per-path, or -// whichever editor is folded last wins and the other's work vanishes. -// --------------------------------------------------------------------- - -/// An `args:` pipeline redacts `city` while a plugin rewrites `token` on -/// the same tool call. Both edits must reach the host. -#[tokio::test] -async fn args_pipeline_and_plugin_edits_both_survive() { - const YAML: &str = r#" -plugins: - - name: token-scrubber - kind: token-scrubber - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - args: - city: "str | redact" - pre_invocation: - - "plugin(token-scrubber)" -"#; - - let mgr = manager_with_yaml( - "token-scrubber", - Box::new(RewriteFactory { - target: Target::ToolCallArgument("token"), - hook: "cmf.tool_pre_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [ - ("city".to_string(), serde_json::json!("London")), - ("token".to_string(), serde_json::json!("sk-secret")), - ] - .into_iter() - .collect(), - namespace: None, - }, - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing, "route should allow"); - let out = forwarded(&result); - assert_eq!( - tool_arg_of(&out, "city"), - Some(serde_json::json!("[REDACTED]")), - "the pipeline's redaction must reach the host" - ); - assert_eq!( - tool_arg_of(&out, "token"), - Some(serde_json::json!("[REDACTED]")), - "the plugin's edit must not be clobbered by folding the pipeline's \ - args back in" - ); -} - -/// The mirror case on the response side: a `result:` pipeline masks one -/// field while a plugin redacts another in the same tool result. -#[tokio::test] -async fn result_pipeline_and_plugin_edits_both_survive() { - const YAML: &str = r#" -plugins: - - name: ssn-redactor - kind: ssn-redactor - hooks: [cmf.tool_post_invoke] -routes: - - tool: get_weather - apl: - result: - employee_id: "str | mask(2)" - post_invocation: - - "plugin(ssn-redactor)" -"#; - - let mgr = manager_with_yaml( - "ssn-redactor", - Box::new(RewriteFactory { - target: Target::ToolResultField("ssn"), - hook: "cmf.tool_post_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of( - Role::Tool, - vec![ContentPart::ToolResult { - content: ToolResult { - tool_call_id: "tc_001".to_string(), - tool_name: "get_weather".to_string(), - content: serde_json::json!({ - "employee_id": "E12345", - "ssn": "123-45-6789", - }), - is_error: false, - }, - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_post_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing, "route should allow"); - let out = forwarded(&result); - let content = tool_result_of(&out).expect("tool result present").clone(); - assert_eq!( - content.get("employee_id"), - Some(&serde_json::json!("****45")), - "the pipeline's mask must reach the host" - ); - assert_eq!( - content.get("ssn"), - Some(&serde_json::json!("[REDACTED]")), - "the plugin's redaction must not be clobbered by folding the \ - pipeline's result back in" - ); -} - -/// A plugin invoked as a pipeline stage on `city` rewrites `city`. The -/// redaction must land on that argument and nowhere else — in -/// particular, the message's unrelated text must not be copied over it. -#[tokio::test] -async fn plugin_stage_rewrites_only_the_field_it_was_pointed_at() { - const YAML: &str = r#" -plugins: - - name: city-scrubber - kind: city-scrubber - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - args: - city: "str | plugin(city-scrubber)" -"#; - - let mgr = manager_with_yaml( - "city-scrubber", - Box::new(RewriteFactory { - target: Target::ToolCallArgument("city"), - hook: "cmf.tool_pre_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of( - Role::User, - vec![ - ContentPart::Text { - text: "chatter that must not become an argument".to_string(), - }, - tool_call_part("London"), - ], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing, "route should allow"); - assert_eq!( - tool_arg_of(&forwarded(&result), "city"), - Some(serde_json::json!("[REDACTED]")) - ); -} - -/// Two plugins, each rewriting a different part. Mutations accumulate -/// across a route's plugin chain, so the last one to run must not be the -/// only one that survives. -#[tokio::test] -async fn mutations_from_several_plugins_accumulate() { - const YAML: &str = r#" -plugins: - - name: result-redactor - kind: result-redactor - hooks: [cmf.tool_pre_invoke] - - name: thought-redactor - kind: thought-redactor - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(result-redactor)" - - "plugin(thought-redactor)" -"#; - - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "result-redactor", - Box::new(RewriteFactory { - target: Target::ToolResultContent, - hook: "cmf.tool_pre_invoke", - }), - ); - mgr.register_factory( - "thought-redactor", - Box::new(RewriteFactory { - target: Target::Thinking, - hook: "cmf.tool_pre_invoke", - }), - ); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let payload = payload_of( - Role::Assistant, - vec![ - ContentPart::Thinking { - text: "ssn is 123-45-6789".to_string(), - }, - tool_result_part("sk-secret-value"), - ], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - let out = forwarded(&result); - assert_eq!( - tool_result_of(&out), - Some(&serde_json::Value::String("[REDACTED]".to_string())), - "the first plugin's redaction must survive the second plugin's dispatch" - ); - assert_eq!( - out.message.get_thinking_content().as_deref(), - Some("[REDACTED]"), - "the second plugin's redaction must land too" - ); -} - -/// A route that mutates and then denies must forward nothing. Half- -/// applying a denied request is worse than either outcome. -#[tokio::test] -async fn a_denied_route_forwards_no_payload() { - const YAML: &str = r#" -plugins: - - name: redactor - kind: redactor - hooks: [cmf.tool_pre_invoke] - - name: denier - kind: denier - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(redactor)" - - "plugin(denier)" -"#; - - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "redactor", - Box::new(RewriteFactory { - target: Target::ToolResultContent, - hook: "cmf.tool_pre_invoke", - }), - ); - mgr.register_factory("denier", Box::new(DenyFactory)); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(YAML).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - - let payload = payload_of(Role::Tool, vec![tool_result_part("sk-secret-value")]); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(!result.continue_processing, "the route must deny"); - assert!( - result.modified_payload.is_none(), - "a denied pipeline carries no payload forward" - ); - assert!(!result.payload_modified); -} - -/// An audit-mode plugin cannot modify: the executor drops whatever -/// payload it returns. Nothing may report that mutation as applied, or -/// the host would forward a payload the framework deliberately rejected. -#[tokio::test] -async fn a_mutation_the_executor_rejects_is_not_reported() { - const YAML: &str = r#" -plugins: - - name: redactor - kind: redactor - hooks: [cmf.tool_pre_invoke] - mode: audit -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(redactor)" -"#; - - let mgr = manager_with_yaml( - "redactor", - Box::new(RewriteFactory { - target: Target::ToolResultContent, - hook: "cmf.tool_pre_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of(Role::Tool, vec![tool_result_part("sk-secret-value")]); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - assert!( - !result.payload_modified, - "an audit-mode plugin's payload is discarded, so no mutation was applied" - ); - assert_eq!( - tool_result_of(&forwarded(&result)), - Some(&serde_json::Value::String("sk-secret-value".to_string())), - "and the original must be what's forwarded" - ); -} - -/// An `omit` stage drops a field while a plugin rewrites another. The -/// removal has to apply to the plugin's payload, not replace it. -#[tokio::test] -async fn omit_stage_and_plugin_edit_both_survive() { - const YAML: &str = r#" -plugins: - - name: token-scrubber - kind: token-scrubber - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - args: - debug: "str | omit" - pre_invocation: - - "plugin(token-scrubber)" -"#; - - let mgr = manager_with_yaml( - "token-scrubber", - Box::new(RewriteFactory { - target: Target::ToolCallArgument("token"), - hook: "cmf.tool_pre_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [ - ("city".to_string(), serde_json::json!("London")), - ("debug".to_string(), serde_json::json!("verbose")), - ("token".to_string(), serde_json::json!("sk-secret")), - ] - .into_iter() - .collect(), - namespace: None, - }, - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - let out = forwarded(&result); - assert_eq!(tool_arg_of(&out, "debug"), None, "the omit must apply"); - assert_eq!( - tool_arg_of(&out, "token"), - Some(serde_json::json!("[REDACTED]")), - "the plugin's edit must not be undone by applying the omit" - ); - assert_eq!( - tool_arg_of(&out, "city"), - Some(serde_json::json!("London")), - "an argument nobody touched must be left alone" - ); -} - -/// Nested arguments: the pipeline redacts `user.ssn`, a plugin rewrites -/// `user.name`. Merging has to descend into the object rather than -/// replace it. -#[tokio::test] -async fn nested_pipeline_and_plugin_edits_both_survive() { - const YAML: &str = r#" -plugins: - - name: name-scrubber - kind: name-scrubber - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - args: - user.ssn: "str | redact" - pre_invocation: - - "plugin(name-scrubber)" -"#; - - let mgr = manager_with_yaml( - "name-scrubber", - Box::new(RewriteFactory { - target: Target::NestedToolCallArgument("user", "name"), - hook: "cmf.tool_pre_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [( - "user".to_string(), - serde_json::json!({"name": "Ada Lovelace", "ssn": "123-45-6789"}), - )] - .into_iter() - .collect(), - namespace: None, - }, - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - let user = tool_arg_of(&forwarded(&result), "user").expect("user argument present"); - assert_eq!( - user.get("ssn"), - Some(&serde_json::json!("[REDACTED]")), - "the pipeline's nested redaction must reach the host" - ); - assert_eq!( - user.get("name"), - Some(&serde_json::json!("[REDACTED]")), - "the plugin's edit to a sibling key must survive the merge" - ); -} - -/// A plugin stage must not undo an earlier stage in its own chain. -/// -/// The payload never sees a pipeline's interim edits, so the field a -/// plugin stage reads back still holds the pre-`mask` value. Treating -/// that as "the plugin's new value" hands the plaintext back to the -/// pipeline and the mask is forwarded undone — the redaction path -/// failing open, which is the whole point of this code. -#[tokio::test] -async fn a_plugin_stage_does_not_undo_an_earlier_mask_in_the_same_chain() { - const YAML: &str = r#" -plugins: - - name: token-scrubber - kind: token-scrubber - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - args: - city: "str | mask(2) | plugin(token-scrubber)" - pre_invocation: - - "plugin(token-scrubber)" -"#; - - // The plugin rewrites `token`, never `city`. It must therefore report - // no change for `city` and leave the mask standing. - let mgr = manager_with_yaml( - "token-scrubber", - Box::new(RewriteFactory { - target: Target::ToolCallArgument("token"), - hook: "cmf.tool_pre_invoke", - }), - YAML, - ) - .await; - - let payload = payload_of( - Role::User, - vec![ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".to_string(), - name: "get_weather".to_string(), - arguments: [ - ("city".to_string(), serde_json::json!("London")), - ("token".to_string(), serde_json::json!("sk-secret")), - ] - .into_iter() - .collect(), - namespace: None, - }, - }], - ); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing, "route should allow"); - let out = forwarded(&result); - assert_eq!( - tool_arg_of(&out, "city"), - Some(serde_json::json!("****on")), - "the mask stage's output must survive the plugin stage that followed it" - ); - assert_eq!( - tool_arg_of(&out, "token"), - Some(serde_json::json!("[REDACTED]")), - "and the plugin's own edit must still reach the host" - ); -} - -/// A route whose plugin allows without mutating, and which has no -/// pipelines, must not report a modification. Reporting one is harmless -/// for correctness but makes every request look modified, so the signal -/// stops meaning anything. -#[tokio::test] -async fn allow_without_mutation_reports_no_modification() { - let mgr = manager_with( - "noop", - Box::new(NoopFactory { - hook: "cmf.tool_pre_invoke", - }), - "cmf.tool_pre_invoke", - "pre_invocation", - ) - .await; - - let payload = payload_of(Role::User, vec![tool_call_part("London")]); - - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", payload, tool_meta(), None) - .await; - - assert!(result.continue_processing); - assert!(!result.payload_modified); - assert_eq!( - tool_arg_of(&forwarded(&result), "city"), - Some(serde_json::json!("London")), - "an untouched request must arrive untouched" - ); -} diff --git a/crates/apl-cpex/tests/restrict_e2e.rs b/crates/apl-cpex/tests/restrict_e2e.rs deleted file mode 100644 index aebe440c..00000000 --- a/crates/apl-cpex/tests/restrict_e2e.rs +++ /dev/null @@ -1,230 +0,0 @@ -// Location: ./crates/apl-cpex/tests/restrict_e2e.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end: an APL route with `restrict` effects, driven through the -// real PluginManager + APL visitor, must fold the emitted constraints -// and surface them on the typed `candidate_constraint` extension slot -// that the host router reads off `PipelineResult.modified_extensions`. -// A `custom`-label contradiction must fail closed. - -use std::sync::Arc; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::extensions::{CandidateConstraintExtension, Extensions, MetaExtension, OnEmpty}; -use cpex_core::manager::PluginManager; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -fn cmf_payload(text: &str) -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, text), - } -} - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - meta -} - -/// Build a manager wired with the APL visitor from `yaml`. `restrict` -/// needs no plugins of its own, so no factories are registered. -async fn build_manager(yaml: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -/// Read the folded constraint off the merged extensions' typed slot. -fn constraint(ext: &Extensions) -> Option { - ext.candidate_constraint.as_ref().map(|arc| (**arc).clone()) -} - -/// A single unconditional `restrict` emits its constraint on -/// `cpex.candidate_constraint`, and the request still continues (restrict -/// never denies). -#[tokio::test] -async fn restrict_emits_constraint_on_side_channel() { - const YAML: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - restrict: { allow_regions: [eu] } -"#; - let mgr = build_manager(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("infer"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - result.continue_processing, - "restrict never denies: violation = {:?}", - result.violation - ); - let merged = result - .modified_extensions - .expect("restrict must surface a constraint via modified_extensions"); - let c = constraint(&merged).expect("candidate_constraint slot must be set"); - assert_eq!(c.allow_regions.as_deref(), Some(&["eu".to_string()][..])); - assert_eq!(c.on_empty, OnEmpty::Deny); -} - -/// Two restricts in the same phase fold: allow-sets intersect, deny-sets -/// union, and the blob is the single folded result. -#[tokio::test] -async fn two_restricts_fold_into_one_blob() { - const YAML: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - restrict: { allow_models: ["vllm/*", "anthropic/*"], deny_models: ["openai/*"] } - - restrict: { allow_models: ["anthropic/*", "cohere/*"] } -"#; - let mgr = build_manager(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("infer"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!(result.continue_processing); - let merged = result.modified_extensions.expect("modified_extensions"); - let c = constraint(&merged).expect("candidate_constraint slot"); - assert_eq!( - c.allow_models.as_deref(), - Some(&["anthropic/*".to_string()][..]) - ); // intersection - assert_eq!(c.deny_models, vec!["openai/*".to_string()]); // union - assert_eq!(c.on_empty, OnEmpty::Deny); -} - -/// A `when`-gated restrict that does NOT fire (gate false) emits no blob. -#[tokio::test] -async fn gated_restrict_absent_when_gate_false() { - const YAML: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - when: "session.labels contains 'eu_resident'" - do: - - restrict: { allow_regions: [eu] } -"#; - let mgr = build_manager(YAML).await; - - // No `eu_resident` label on the session → gate is false. - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("infer"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!(result.continue_processing); - // Either no modified_extensions at all, or one with an empty slot. - let has_constraint = result - .modified_extensions - .as_ref() - .and_then(constraint) - .is_some(); - assert!( - !has_constraint, - "gate was false — no constraint should be emitted" - ); -} - -/// Two restricts requiring the same `custom` label to differ is an -/// unsatisfiable contradiction — the request fails closed with a -/// `policy.restrict_conflict` violation. -#[tokio::test] -async fn conflicting_custom_labels_fail_closed() { - const YAML: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - restrict: { custom: { gpu: h100 } } - - restrict: { custom: { gpu: a100 } } -"#; - let mgr = build_manager(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("infer"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - !result.continue_processing, - "contradictory custom labels must fail closed" - ); - let violation = result.violation.expect("conflict must surface a violation"); - assert_eq!(violation.code, "policy.restrict_conflict"); -} - -/// Restricts in separate `parallel:` branches both merge back into the -/// single folded constraint — accumulation survives the fan-out, driven -/// end-to-end through the pipeline (not just the evaluator unit path). -#[tokio::test] -async fn parallel_branch_restricts_accumulate() { - const YAML: &str = r#" -routes: - - tool: infer - apl: - pre_invocation: - - parallel: - - restrict: { allow_regions: [eu, us] } - - restrict: { deny_models: ["openai/*"] } -"#; - let mgr = build_manager(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("infer"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!(result.continue_processing); - let merged = result.modified_extensions.expect("modified_extensions"); - let c = constraint(&merged).expect("candidate_constraint slot"); - // Branch A's allow_regions and branch B's deny_models both survive the - // fold (order-agnostic — the fold may reorder). - let mut regions = c - .allow_regions - .clone() - .expect("allow_regions from branch A"); - regions.sort(); - assert_eq!(regions, vec!["eu".to_string(), "us".to_string()]); - assert_eq!(c.deny_models, vec!["openai/*".to_string()]); -} diff --git a/crates/apl-cpex/tests/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs deleted file mode 100644 index edf2a51f..00000000 --- a/crates/apl-cpex/tests/visitor_e2e.rs +++ /dev/null @@ -1,913 +0,0 @@ -// Location: ./crates/apl-cpex/tests/visitor_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end integration: unified-config YAML → cpex-core -// `load_config_yaml` → `AplConfigVisitor` walks global / defaults / tags -// / routes → `PluginManager::annotate_route` installs phase-bound -// `AplRouteHandler`s → host calls `invoke_named::` with meta → -// route-annotation short-circuit fires the handler → APL evaluator runs -// the layered route → real CPEX plugins dispatch through -// `CmfPluginInvoker` inside the handler. -// -// This is the load-bearing test for the visitor + annotation flow. It -// proves the whole hierarchy collapses into per-route handlers exactly -// once at load time, and that dispatch into those handlers behaves like -// any other plugin entry (mode, on_error, capabilities all honored -// because the synthetic plugin's `PluginConfig` flows through the same -// executor path). - -use std::sync::Arc; - -use async_trait::async_trait; - -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, Message, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError as CoreError, PluginViolation}; -use cpex_core::extensions::MetaExtension; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; - -// ===================================================================== -// Test plugins — `allow-gate` (passes through) and `deny-gate` (denies). -// Both register on `cmf.tool_pre_invoke`. APL routes reference them by -// name via `plugin()` in the YAML; the visitor stacks them into -// the route's compiled steps; the handler dispatches into them through -// CmfPluginInvoker. -// ===================================================================== - -struct AllowGate { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for AllowGate { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AllowGate { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } -} - -struct AllowGateFactory; -impl PluginFactory for AllowGateFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AllowGate { - cfg: config.clone(), - }); - // Register the handler under every hook the operator declared - // in `hooks: [...]`. Lets tests pin the plugin to llm / prompt - // / resource hooks via YAML without per-entity factory copies. - let handlers = hooks_for(config, plugin.clone()); - Ok(PluginInstance { plugin, handlers }) - } -} - -/// Build the adapter list for a plugin from the operator-declared -/// `hooks:` config. Falls back to `cmf.tool_pre_invoke` when nothing -/// is declared (matches v0 default for routes that don't specify). -fn hooks_for( - config: &PluginConfig, - plugin: Arc, -) -> Vec<(&'static str, Arc)> -where - H: HookHandler + Plugin + 'static, -{ - let hook_names: Vec<&'static str> = if config.hooks.is_empty() { - vec!["cmf.tool_pre_invoke"] - } else { - config - .hooks - .iter() - .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str) - .collect() - }; - hook_names - .into_iter() - .map(|name| { - let adapter: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - (name, adapter) - }) - .collect() -} - -struct DenyGate { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for DenyGate { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for DenyGate { - async fn handle( - &self, - _payload: &MessagePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(PluginViolation::new("policy.forbidden", "deny-gate fired")) - } -} - -struct DenyGateFactory; -impl PluginFactory for DenyGateFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(DenyGate { - cfg: config.clone(), - }); - let handlers = hooks_for(config, plugin.clone()); - Ok(PluginInstance { plugin, handlers }) - } -} - -// ===================================================================== -// Helpers -// ===================================================================== - -fn cmf_payload(text: &str) -> MessagePayload { - MessagePayload { - message: Message::text(Role::User, text), - } -} - -fn meta_for_tool(name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some("tool".to_string()); - meta.entity_name = Some(name.to_string()); - meta -} - -/// Build a manager with `allow-gate` and `deny-gate` factories registered, -/// then wire the APL visitor in via `register_apl`. Returns -/// `Arc` so the caller can dispatch through -/// `invoke_named`. The visitor self-populates its plugin registry from -/// cpex-core's parsed `Vec` via `visit_plugins` — no host -/// pre-parse needed. -async fn build_manager_with_visitor(yaml: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); - mgr.register_factory("deny-gate", Box::new(DenyGateFactory)); - - register_apl( - &mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories: Vec::new(), - session_store_factories: Vec::new(), - base_capabilities: None, - }, - ); - - mgr.load_config_yaml(yaml).expect("load_config_yaml"); - mgr.initialize().await.expect("initialize"); - mgr -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Route declares an `apl.policy: [plugin(allow-gate)]`. After the -/// visitor walks the config, `cmf.tool_pre_invoke` for tool `get_weather` -/// must short-circuit to the APL handler, which dispatches the policy -/// step into the registered `allow-gate` plugin → allow. -#[tokio::test] -async fn visitor_route_with_allow_plugin_returns_allow() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - result.continue_processing, - "allow path should continue: violation = {:?}", - result.violation - ); -} - -/// Same shape but with `deny-gate`. The visitor compiles the route, -/// annotates the manager, dispatch goes through the handler, the handler -/// calls into deny-gate via CmfPluginInvoker, the violation propagates -/// out as `PipelineResult.violation` with the original code + reason. -#[tokio::test] -async fn visitor_route_with_deny_plugin_propagates_violation() { - const YAML: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(deny-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!(!result.continue_processing, "deny path should halt"); - let violation = result - .violation - .expect("deny path must surface a violation"); - assert_eq!( - violation.reason, "deny-gate fired", - "violation reason must propagate from the plugin through the handler" - ); - assert_eq!(violation.code, "policy.forbidden"); -} - -/// Hierarchy: global APL policy step runs FIRST, then route APL policy. -/// Tests apply_layer ordering — global's `plugin(allow-gate)` runs and -/// passes, then route's `plugin(deny-gate)` fires and denies. If the -/// global layer had been appended after instead of before, the deny -/// would have run first and we'd see the deny path; the order assertion -/// is implicit in the violation reason coming from deny-gate. -#[tokio::test] -async fn visitor_stacks_global_then_route_in_order() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -global: - apl: - pre_invocation: - - "plugin(allow-gate)" -routes: - - tool: get_weather - apl: - pre_invocation: - - "plugin(deny-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - let violation = result.violation.expect("route-level deny must fire"); - assert_eq!(violation.reason, "deny-gate fired"); -} - -/// Tag bundle stacks on top of global. A route tagged `pii` inherits -/// `plugin(deny-gate)` from the tag bundle even though the route itself -/// declares no APL block — proves tag layers are applied without the -/// route having to redeclare anything. -#[tokio::test] -async fn visitor_applies_tag_bundle_to_tagged_route() { - const YAML: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -global: - policies: - pii: - apl: - pre_invocation: - - "plugin(deny-gate)" -routes: - - tool: get_weather - meta: - tags: [pii] -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - let violation = result - .violation - .expect("tag bundle's deny-gate should propagate"); - assert_eq!(violation.reason, "deny-gate fired"); -} - -/// Scope routing: a scoped annotation overrides the unscoped default for -/// the matching scope, while requests in other scopes fall back to the -/// unscoped annotation. Proves the visitor's `meta.scope` propagation is -/// keying annotations correctly through cpex-core's annotation table. -#[tokio::test] -async fn visitor_scoped_annotation_overrides_unscoped() { - // Two routes for the same tool: one scoped to `vs-a`, one unscoped. - // The scoped route denies; the unscoped route allows. A request in - // scope `vs-a` must hit the scoped annotation (deny); a request in - // scope `vs-b` falls back to the unscoped default (allow). - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - meta: - scope: vs-a - apl: - pre_invocation: - - "plugin(deny-gate)" - - tool: get_weather - apl: - pre_invocation: - - "plugin(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - // Scope vs-a → scoped annotation → deny. - let mut meta_a = meta_for_tool("get_weather"); - meta_a.scope = Some("vs-a".to_string()); - let ext_a = Extensions { - meta: Some(Arc::new(meta_a)), - ..Default::default() - }; - let (res_a, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext_a, None) - .await; - let v = res_a.violation.expect("scoped annotation should deny"); - assert_eq!(v.reason, "deny-gate fired"); - - // Scope vs-b → no scoped match → fall back to unscoped annotation → allow. - let mut meta_b = meta_for_tool("get_weather"); - meta_b.scope = Some("vs-b".to_string()); - let ext_b = Extensions { - meta: Some(Arc::new(meta_b)), - ..Default::default() - }; - let (res_b, _) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext_b, None) - .await; - assert!( - res_b.continue_processing, - "unscoped fall-back should allow (got violation: {:?})", - res_b.violation - ); -} - -/// Sanity-check: an empty plugin registry + no APL blocks anywhere -/// means the visitor installs zero annotations and the manager behaves -/// exactly as if no visitor was registered. Smokes the no-op path. -#[tokio::test] -async fn visitor_with_no_apl_blocks_installs_nothing() { - // No `apl:` blocks anywhere — just a route + plugin that wouldn't - // be referenced from any APL step. - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: anything -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("anything"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - // Without APL annotations the route resolves through the legacy - // chain. allow-gate is registered but the route doesn't reference - // it, so it doesn't fire either. The pipeline returns allow. - assert!(result.continue_processing); - assert!(result.violation.is_none()); -} - -/// A bare `global: { response: {...} }` — a denyWith with no accompanying -/// `apl:` policy/args block — must load cleanly (the visitor warns and moves -/// on) rather than panicking or erroring. `visit_global` returns early when -/// `apl_subblock` finds no APL terms; this guards that the stranded -/// `response:` on that early-return path is handled, not silently exploded. -#[tokio::test] -async fn global_response_without_apl_block_loads_without_error() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] -global: - response: - status: 403 - body: "forbidden" -routes: - - tool: anything -"#; - // The load must not panic or return Err despite the response-only global - // block having no installable policy. A request still flows through the - // legacy chain (no catch-all handler was installed for the entity-less - // path, which is the documented behavior this warns about). - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("anything"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - assert!(result.continue_processing); - assert!(result.violation.is_none()); -} - -/// Smoke test that the visitor surfaces a compile error from a malformed -/// APL block as a `PluginError::Config` out of `load_config_yaml`. Catches -/// regressions where visitor errors swallow into Ok(_) or panic. -// --------------------------------------------------------------------- -// Multi-entity-type route support (llm / prompt / resource) -// --------------------------------------------------------------------- -// -// Previously, the visitor hardcoded annotation on -// `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` regardless of route -// entity_type — so an `llm:` route would silently bind to the tool -// hooks and never fire when the host called `invoke_named::("cmf.llm_input", ...)`. -// These tests pin per-entity routing. - -fn meta_for_entity(entity_type: &str, entity_name: &str) -> MetaExtension { - let mut meta = MetaExtension::default(); - meta.entity_type = Some(entity_type.to_string()); - meta.entity_name = Some(entity_name.to_string()); - meta -} - -/// `llm:` route → annotation lands on `cmf.llm_input`. Host calling -/// `invoke_named::("cmf.llm_input", ...)` with matching meta -/// fires the AplRouteHandler. -#[tokio::test] -async fn llm_route_annotates_on_llm_input_hook() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.llm_input] -routes: - - llm: gpt-4 - apl: - pre_invocation: - - "plugin(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.llm_input", cmf_payload("hi"), ext, None) - .await; - - assert!( - result.continue_processing, - "llm route should fire on cmf.llm_input: violation = {:?}", - result.violation - ); -} - -/// Same llm route but post — annotation lands on `cmf.llm_output`. -/// Previously, this would have annotated on `cmf.tool_post_invoke` -/// and never matched. -#[tokio::test] -async fn llm_route_annotates_on_llm_output_hook_for_post_phase() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.llm_output] -routes: - - llm: gpt-4 - apl: - post_invocation: - - "plugin(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.llm_output", cmf_payload("response"), ext, None) - .await; - - assert!( - result.continue_processing, - "llm route post-phase should fire on cmf.llm_output: violation = {:?}", - result.violation - ); -} - -/// `prompt:` route → annotation lands on `cmf.prompt_pre_invoke`. -#[tokio::test] -async fn prompt_route_annotates_on_prompt_pre_invoke_hook() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.prompt_pre_invoke] -routes: - - prompt: summarize_email - apl: - pre_invocation: - - "plugin(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_entity("prompt", "summarize_email"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.prompt_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - result.continue_processing, - "prompt route should fire on cmf.prompt_pre_invoke: violation = {:?}", - result.violation - ); -} - -/// `resource:` route → annotation lands on `cmf.resource_pre_fetch`. -#[tokio::test] -async fn resource_route_annotates_on_resource_pre_fetch_hook() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.resource_pre_fetch] -routes: - - resource: hr://employees/* - apl: - pre_invocation: - - "plugin(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_entity( - "resource", - "hr://employees/E001234", - ))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.resource_pre_fetch", cmf_payload("hi"), ext, None) - .await; - - assert!( - result.continue_processing, - "resource route should fire on cmf.resource_pre_fetch: violation = {:?}", - result.violation - ); -} - -/// Cross-check: an llm route's APL annotation MUST NOT install on -/// `cmf.tool_pre_invoke`. Previously, the visitor would have -/// annotated llm routes on the tool hook by mistake; this test pins -/// that the bug is gone. -/// -/// Setup: plugin registered ONLY under `cmf.llm_input`. The llm -/// route's APL annotation lands (post-Slice-102) on `cmf.llm_input`. -/// Calling `invoke_named::("cmf.tool_pre_invoke", ...)` -/// finds no APL annotation for that hook AND no plugin chain entry -/// for it → returns `continue_processing=true` with no violations. -/// Calling `cmf.llm_input` DOES fire the annotation and the deny. -#[tokio::test] -async fn llm_route_does_not_fire_on_tool_hook() { - const YAML: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.llm_input] -routes: - - llm: gpt-4 - apl: - pre_invocation: - - "plugin(deny-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), - ..Default::default() - }; - - // Calling cmf.tool_pre_invoke must NOT trigger the llm route's - // APL annotation. With no annotation AND no plugin registered on - // cmf.tool_pre_invoke, dispatch returns continue. - let (tool_result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext.clone(), None) - .await; - assert!( - tool_result.continue_processing, - "llm route MUST NOT bind to cmf.tool_pre_invoke (pre-Slice-102 bug); \ - violation = {:?}", - tool_result.violation, - ); - - // Sanity: calling the RIGHT hook (cmf.llm_input) DOES fire the - // annotation, hits deny-gate, denies — proves the route is wired - // correctly on the llm hook side. - let (llm_result, _bg) = mgr - .invoke_named::("cmf.llm_input", cmf_payload("hi"), ext, None) - .await; - assert!( - !llm_result.continue_processing, - "cmf.llm_input dispatch should hit the deny-gate via the llm route", - ); -} - -#[tokio::test] -async fn visitor_compile_error_propagates_from_load_config_yaml() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - pre_invocation: - - "this-is-not-a-valid-step ::: $$$" -"#; - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); - register_apl(&mgr, AplOptions::in_process()); - - let err = mgr - .load_config_yaml(YAML) - .expect_err("malformed APL block must error"); - let msg = format!("{}", err); - assert!( - msg.contains("visitor 'apl'"), - "expected visitor error context, got: {}", - msg - ); -} - -/// Flat form: a route may declare `pre_invocation:` directly, without the `apl:` -/// wrapper. The visitor recognizes it identically to the wrapped form. -/// (Also exercises the `run(...)` plugin alias.) -#[tokio::test] -async fn visitor_flat_route_without_apl_wrapper_allows() { - const YAML: &str = r#" -plugins: - - name: allow-gate - kind: allow-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - pre_invocation: - - "run(allow-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - result.continue_processing, - "flat (no-apl-wrapper) allow path should continue: violation = {:?}", - result.violation - ); -} - -/// Flat form deny mirrors the wrapped deny path — the route's `pre_invocation:` -/// is honored without an `apl:` wrapper and the violation propagates. -#[tokio::test] -async fn visitor_flat_route_without_apl_wrapper_denies() { - const YAML: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - pre_invocation: - - "plugin(deny-gate)" -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!(!result.continue_processing, "flat deny path should halt"); - let violation = result - .violation - .expect("deny path must surface a violation"); - assert_eq!(violation.reason, "deny-gate fired"); -} - -// ===================================================================== -// Flat `plugins:` MAP form (no `apl:` wrapper) — regression coverage -// for the load-path bug where a route/defaults/policy `plugins:` map -// failed to deserialize into `Vec` *before* any visitor -// ran. The structural parse now tolerates the map (treats it as APL -// per-plugin override data and leaves the structural list empty); the -// APL visitor consumes the map from the raw YAML. These tests drive the -// map through the real `load_config_yaml` path the unit tests can't hit. -// ===================================================================== - -/// A route with a flat `pre_invocation:` AND a flat `plugins:` *map* override -/// (no `apl:` wrapper) loads through `load_config_yaml` (previously a -/// hard `invalid type: map, expected a sequence` error) and the policy -/// still fires — proving the override map and the activating policy -/// coexist on the same section. -#[tokio::test] -async fn flat_route_with_plugins_map_and_policy_loads_and_denies() { - const YAML: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - pre_invocation: - - "plugin(deny-gate)" - plugins: - deny-gate: - on_error: ignore -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - !result.continue_processing, - "flat plugins-map route should still run its policy and deny" - ); - let violation = result - .violation - .expect("deny path must surface a violation"); - assert_eq!(violation.reason, "deny-gate fired"); -} - -/// The flat `plugins:` map form must be behaviorally identical to the -/// `apl: { plugins: {...} }` wrapper form — that equivalence is the -/// whole point of "the wrapper is optional". An override map alone -/// declares no phases, so neither form installs an APL handler; whatever -/// the legacy chain then does, both forms must do the same thing. We -/// assert the two routes resolve to the same decision rather than -/// hard-coding the legacy-chain outcome (which this PR doesn't touch). -#[tokio::test] -async fn flat_plugins_map_only_matches_wrapped_plugins_map_only() { - const FLAT: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - plugins: - deny-gate: - on_error: ignore -"#; - const WRAPPED: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - apl: - plugins: - deny-gate: - on_error: ignore -"#; - - async fn decide(yaml: &str) -> bool { - let mgr = build_manager_with_visitor(yaml).await; - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - result.continue_processing - } - - assert_eq!( - decide(FLAT).await, - decide(WRAPPED).await, - "flat plugins-map and apl-wrapped plugins-map must resolve identically", - ); -} - -/// A `plugins:` map at `global.defaults.` scope loads through -/// the full pipeline. Before the fix this failed at the structural -/// `CpexConfig` parse (the defaults group's `plugins` is also a `Vec`). -/// The default layer contributes the policy; the route inherits it. -#[tokio::test] -async fn flat_defaults_plugins_map_loads_through_full_pipeline() { - const YAML: &str = r#" -plugins: - - name: deny-gate - kind: deny-gate - hooks: [cmf.tool_pre_invoke] -global: - defaults: - tool: - pre_invocation: - - "plugin(deny-gate)" - plugins: - deny-gate: - on_error: ignore -routes: - - tool: get_weather -"#; - let mgr = build_manager_with_visitor(YAML).await; - - let ext = Extensions { - meta: Some(Arc::new(meta_for_tool("get_weather"))), - ..Default::default() - }; - let (result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) - .await; - - assert!( - !result.continue_processing, - "tool default with a flat plugins-map override should still deny via inherited policy" - ); - assert_eq!( - result.violation.expect("deny expected").reason, - "deny-gate fired" - ); -} diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml deleted file mode 100644 index 13383363..00000000 --- a/crates/cpex-builtins/Cargo.toml +++ /dev/null @@ -1,70 +0,0 @@ -# Location: ./crates/cpex-builtins/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Fred Araujo -# -# cpex-builtins — the bundled CPEX extension set behind cargo features. -# -# A host depends on this one crate to get the built-in plugins, PDPs, and -# session stores; the feature list picks exactly what compiles in and gets -# registered. `register_builtins` / `builtin_pdps` / -# `builtin_session_store_factories` are #[cfg]-gated to the selected -# features, so there is no code change between configurations — only the -# feature list. Both the `cpex` facade and `cpex-ffi` delegate here so the -# registration set lives in one place. - -[package] -name = "cpex-builtins" -description = "CPEX built-in plugins, PDPs, and session stores with feature-gated registration." -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[features] -# The common in-process set: the four hook plugins plus both PDPs. Heavier -# / external-backend extensions (valkey) stay opt-in via `full`. -default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "elicitation-ciba", "cedar-direct", "cel", "opa"] - -# Each feature pulls exactly one builtin crate. -pii-scanner = ["dep:cpex-plugin-pii-scanner"] -audit-logger = ["dep:cpex-plugin-audit-logger"] -identity-jwt = ["dep:cpex-plugin-identity-jwt"] -delegator-oauth = ["dep:cpex-plugin-delegator-oauth"] -elicitation-ciba = ["dep:cpex-plugin-elicitation-ciba"] -cedar-direct = ["dep:cpex-pdp-cedar-direct"] -cel = ["dep:cpex-pdp-cel"] -opa = ["dep:cpex-pdp-opa"] -valkey = ["dep:cpex-session-valkey"] - -# Everything wired here, including the Valkey session store (redis client + -# rustls TLS stack). -full = ["default", "valkey"] - -[dependencies] -# Registration targets — the manager, PDP factory trait, and APL options. -cpex-core = { workspace = true } -apl-core = { workspace = true } -apl-cpex = { workspace = true } - -# Builtin extension crates — each behind its feature. -cpex-plugin-pii-scanner = { workspace = true, optional = true } -cpex-plugin-audit-logger = { workspace = true, optional = true } -cpex-plugin-identity-jwt = { workspace = true, optional = true } -cpex-plugin-delegator-oauth = { workspace = true, optional = true } -cpex-plugin-elicitation-ciba = { workspace = true, optional = true } -cpex-pdp-cedar-direct = { workspace = true, optional = true } -cpex-pdp-cel = { workspace = true, optional = true } -cpex-pdp-opa = { workspace = true, optional = true } -cpex-session-valkey = { workspace = true, optional = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } - -[lints] -workspace = true diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs deleted file mode 100644 index edf999f6..00000000 --- a/crates/cpex-builtins/src/lib.rs +++ /dev/null @@ -1,174 +0,0 @@ -// Location: ./crates/cpex-builtins/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Fred Araujo - -//! CPEX built-in extension set. -//! -//! One crate that bundles the first-party plugins, PDPs, and session -//! stores, each behind a cargo feature. A host depends on `cpex-builtins` -//! (directly, or transitively through the `cpex` facade) and selects what -//! compiles in via the feature list: -//! -//! ```toml -//! # the in-process default set -//! cpex-builtins = "0.2" -//! # a minimal subset -//! cpex-builtins = { version = "0.2", default-features = false, features = ["pii-scanner"] } -//! ``` -//! -//! Then [`install_builtins`] registers every enabled factory and installs -//! the APL config visitor in one call, or use the building blocks -//! ([`register_builtins`], [`builtin_pdps`], -//! [`builtin_session_store_factories`]) to assemble an [`AplOptions`] -//! yourself. -//! -//! Registration is **explicit** (the [`register_builtins`] macro expands to -//! `#[cfg]`-gated `register_factory` calls), not `inventory`/`linkme`-style -//! link-section discovery — so the factory symbols survive the linker's -//! dead-code GC when this crate is compiled into the `cpex-ffi` staticlib. - -use std::sync::Arc; - -use apl_core::step::PdpFactory; -use apl_cpex::{register_apl, AplOptions, SessionStoreFactory}; -use cpex_core::manager::PluginManager; - -#[cfg(feature = "cedar-direct")] -pub use cpex_pdp_cedar_direct::CedarDirectPdpFactory; -#[cfg(feature = "cel")] -pub use cpex_pdp_cel::CelPdpFactory; -#[cfg(feature = "opa")] -pub use cpex_pdp_opa::OpaPdpFactory; -#[cfg(feature = "audit-logger")] -pub use cpex_plugin_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; -#[cfg(feature = "delegator-oauth")] -pub use cpex_plugin_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND}; -#[cfg(feature = "elicitation-ciba")] -pub use cpex_plugin_elicitation_ciba::{CibaApproverFactory, KIND as CIBA_KIND}; -#[cfg(feature = "identity-jwt")] -pub use cpex_plugin_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; -#[cfg(feature = "pii-scanner")] -pub use cpex_plugin_pii_scanner::{PiiScannerFactory, KIND as PII_KIND}; -#[cfg(feature = "valkey")] -pub use cpex_session_valkey::{ValkeyConfig, ValkeySessionStoreFactory, KIND as VALKEY_KIND}; - -/// Generate [`register_builtins`] from a feature → factory table. Each entry -/// expands to a `#[cfg(feature = ...)]`-gated, **explicit** -/// `register_factory(KIND, Box::new(Factory))` call keyed off the builtin -/// crate's own `KIND` const. -/// -/// Explicit calls (vs `inventory`/`linkme` link-section registration) are -/// deliberate: in the `cpex-ffi` staticlib the linker GCs sections nothing -/// references, which would silently drop auto-registered plugins. Naming -/// each factory here keeps its object code alive. -macro_rules! register_builtins { - ( $( feature $feat:literal => $krate:ident :: $factory:ident ),* $(,)? ) => { - /// Register every enabled by-kind plugin factory on `mgr`: identity - /// (`identity-jwt`), delegators (`delegator-oauth`), validators - /// (`pii-scanner`), and observers (`audit-logger`). Call before - /// loading a config so the manager can instantiate plugins whose - /// YAML `kind:` matches. - /// - /// PDP and session-store factories are wired through [`AplOptions`] - /// instead; see [`builtin_pdps`] and - /// [`builtin_session_store_factories`], or use [`install_builtins`]. - #[allow(unused_variables)] - pub fn register_builtins(mgr: &Arc) { - $( - #[cfg(feature = $feat)] - mgr.register_factory($krate::KIND, Box::new($krate::$factory)); - )* - } - }; -} - -register_builtins! { - feature "identity-jwt" => cpex_plugin_identity_jwt::JwtIdentityFactory, - feature "delegator-oauth" => cpex_plugin_delegator_oauth::OAuthDelegatorFactory, - feature "elicitation-ciba" => cpex_plugin_elicitation_ciba::CibaApproverFactory, - feature "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory, - feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory, -} - -/// The enabled PDP factories, ready to drop into -/// [`AplOptions::pdp_factories`]. A route's `cedar:` or `cel:` step selects -/// which one runs. -// `vec![]` can't replace the conditional pushes: each element is -// `#[cfg]`-gated on its feature, so the set is built incrementally. -#[allow(unused_mut, clippy::vec_init_then_push)] -pub fn builtin_pdps() -> Vec> { - let mut factories: Vec> = Vec::new(); - #[cfg(feature = "cedar-direct")] - factories.push(Arc::new(cpex_pdp_cedar_direct::CedarDirectPdpFactory::new())); - #[cfg(feature = "cel")] - factories.push(Arc::new(cpex_pdp_cel::CelPdpFactory::new())); - #[cfg(feature = "opa")] - factories.push(Arc::new(cpex_pdp_opa::OpaPdpFactory::new())); - factories -} - -/// The enabled session-store factories, ready to drop into -/// [`AplOptions::session_store_factories`]. A `global.apl.session_store: -/// { kind: ... }` config block selects one; absent that, the in-process -/// `MemorySessionStore` default stays active. -#[allow(unused_mut, clippy::vec_init_then_push)] -pub fn builtin_session_store_factories() -> Vec> { - let mut factories: Vec> = Vec::new(); - #[cfg(feature = "valkey")] - factories.push(Arc::new( - cpex_session_valkey::ValkeySessionStoreFactory::new(), - )); - factories -} - -/// Register every enabled plugin factory and install the APL config visitor -/// on `mgr` with in-process defaults (a `MemorySessionStore` and the default -/// baseline capabilities). The enabled PDP and session-store factories are -/// wired in, so a later config load can reference any of them by `kind`. -/// -/// This is the one-call path; reach for [`register_builtins`] and -/// [`AplOptions`] directly when you need to customize capabilities or the -/// default store. -pub fn install_builtins(mgr: &Arc) { - register_builtins(mgr); - - let mut opts = AplOptions::in_process(); - opts.pdp_factories = builtin_pdps(); - opts.session_store_factories = builtin_session_store_factories(); - - let _visitor = register_apl(mgr, opts); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn install_builtins_runs_without_panic() { - let mgr = Arc::new(PluginManager::default()); - install_builtins(&mgr); - } - - #[test] - fn pdp_factories_track_enabled_features() { - let expected = cfg!(feature = "cedar-direct") as usize - + cfg!(feature = "cel") as usize - + cfg!(feature = "opa") as usize; - assert_eq!( - builtin_pdps().len(), - expected, - "one PDP factory per enabled feature", - ); - } - - #[test] - fn session_store_factories_track_enabled_features() { - let expected = cfg!(feature = "valkey") as usize; - assert_eq!( - builtin_session_store_factories().len(), - expected, - "one session-store factory per enabled feature", - ); - } -} diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml deleted file mode 100644 index 1f0d90aa..00000000 --- a/crates/cpex-core/Cargo.toml +++ /dev/null @@ -1,47 +0,0 @@ -# Location: ./crates/cpex-core/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# CPEX Core — pure Rust plugin runtime with no FFI dependencies. -# Contains the PluginManager, 5-phase executor, hook registry, -# config parser, and all core types. - -[package] -name = "cpex-core" -description = "CPEX plugin runtime core — PluginManager, executor, hooks, and config." -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -tokio = { workspace = true } -tokio-util = { workspace = true } -serde = { workspace = true } -serde_yaml = { workspace = true } -serde_json = { workspace = true } -async-trait = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -uuid = { workspace = true } -hashbrown = { workspace = true } -arc-swap = { workspace = true } -wildmatch = { workspace = true } -chrono = { workspace = true } -# Zeroizing wrapper for raw credential material in RawCredentialsExtension. -# `derive` feature pulls the proc-macro so we can `#[derive(Zeroize)]` on -# token-bearing structs in a future slice; for now only the -# `Zeroizing` wrapper is used directly. -zeroize = { version = "1.8", features = ["zeroize_derive"] } -# Shared concurrency primitive used by `executor::run_concurrent_phase` -# (and apl-core's `Effect::Parallel`). Leaf crate, no cycles back here. -cpex-orchestration = { workspace = true } - -[lints] -workspace = true diff --git a/crates/cpex-core/examples/README.md b/crates/cpex-core/examples/README.md deleted file mode 100644 index c3962e31..00000000 --- a/crates/cpex-core/examples/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# CPEX Core Examples - -## plugin_demo - -A complete end-to-end example showing how to build plugins, load config, and invoke hooks with the CPEX runtime. - -### What it demonstrates - -- **Defining hook types and payloads** — `ToolPreInvoke` and `ToolPostInvoke` hooks with a shared `ToolInvokePayload` -- **Building plugins** — three plugins (`IdentityResolver`, `PiiGuard`, `AuditLogger`) implementing `Plugin` + `HookHandler` for different hook types -- **Multi-hook registration** — a single plugin instance (e.g., `IdentityResolver`) registered for multiple hooks (`tool_pre_invoke` and `tool_post_invoke`) via the factory pattern -- **Plugin factories** — `PluginFactory` implementations that create plugin instances and wire up typed handler adapters -- **YAML config loading** — `plugin_demo.yaml` declares plugins, policy groups, and routing rules -- **Policy groups and tag-based routing** — the `pii` policy group activates `PiiGuard` only for tools tagged with `pii` -- **Route resolution** — exact tool matches, wildcard catch-all, tag-driven plugin selection -- **PluginContext** — `global_state` used to pass PII clearance between hooks, `local_state` for per-plugin scratch data -- **BackgroundTasks** — fire-and-forget plugins (`AuditLogger`) spawn background tasks; `wait_for_background_tasks()` awaits them -- **PluginContextTable** — context table threaded from pre-invoke to post-invoke to preserve plugin state - -### Running - -From the workspace root: - -``` -cargo run --example plugin_demo -``` - -### Scenarios - -The demo runs five scenarios against three registered plugins: - -| Scenario | Tool | User | Outcome | -|----------|------|------|---------| -| 1 | get_compensation | alice (no clearance) | DENIED by pii-guard | -| 2 | get_compensation | alice (with clearance) | ALLOWED, then post-invoke fires | -| 3 | list_departments | bob | ALLOWED (no PII tag, pii-guard skipped) | -| 4 | some_other_tool | charlie | ALLOWED (wildcard route) | -| 5 | list_departments | (empty) | DENIED by identity-resolver | - -### Files - -- `plugin_demo.rs` — Rust source with plugins, factories, and main -- `plugin_demo.yaml` — YAML config with plugins, policy groups, and routes - ---- - -## cmf_capabilities_demo - -Demonstrates CMF messages with capability-gated extension access. Shows how different plugins see different views of the same extensions based on their declared capabilities. - -### What it demonstrates - -- **CMF Message** — typed content parts (`Text`, `ToolCall`) with the standard CMF format -- **Capability gating** — plugins declare capabilities in YAML config; the executor filters extensions per plugin -- **Security labels** — `MonotonicSet` (add-only, no remove at compile time) -- **Guarded HTTP headers** — `.read()` is free, `.write(token)` requires a `WriteToken` -- **COW copy** — `extensions.cow_copy()` for plugins that need to modify; zero-cost for read-only plugins -- **Write tokens** — executor sets tokens based on capabilities; propagated through `cow_copy()` -- **Three capability levels** — identity-checker (security), header-injector (http + labels), audit-logger (http + labels read-only) - -### Running - -From the workspace root: - -``` -cargo run --example cmf_capabilities_demo -``` - -### What each plugin sees - -| Plugin | Capabilities | Security Labels | Subject | HTTP Headers | Can Write | -|--------|-------------|-----------------|---------|--------------|-----------| -| identity-checker | read_labels, read_subject, read_roles | visible | visible (id + roles) | hidden | no | -| header-injector | read_headers, write_headers, append_labels | visible | hidden | visible | yes (headers + labels) | -| audit-logger | read_headers, read_labels | visible | hidden | visible | no (audit mode) | - -### Files - -- `cmf_capabilities_demo.rs` — Rust source with CMF plugins and capability-gated access -- `cmf_capabilities_demo.yaml` — YAML config with per-plugin capabilities diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs deleted file mode 100644 index 8843a30e..00000000 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ /dev/null @@ -1,498 +0,0 @@ -// CMF Capabilities Demo -// -// Demonstrates: -// 1. CMF Message with typed content parts (tool call) -// 2. Extensions with security, HTTP, and meta populated -// 3. Config-driven capability gating — plugins only see what they declare -// 4. COW copy for extension modification with write tokens -// 5. MonotonicSet labels (add-only, no remove) -// 6. Guarded HTTP headers (read free, write needs token) -// -// Run with: cargo run --example cmf_capabilities_demo - -use std::sync::Arc; - -use async_trait::async_trait; -use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::extensions::{HttpExtension, RequestExtension, SecurityExtension}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::{Extensions, MetaExtension}; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -// --------------------------------------------------------------------------- -// Plugin: IdentityChecker -// Has read_security, read_labels, read_subject, read_roles capabilities. -// Checks if the caller has the required role. -// --------------------------------------------------------------------------- - -struct IdentityChecker { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for IdentityChecker { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for IdentityChecker { - async fn handle( - &self, - payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // Determine if this is pre or post invoke based on message content - let is_result = payload.message.is_tool_result(); - - if is_result { - // POST-INVOKE: verify the tool result came from an authorized call - let tool_name = payload - .message - .get_tool_results() - .first() - .map(|tr| tr.tool_name.as_str()) - .unwrap_or("unknown"); - println!( - " [identity-checker] POST-INVOKE: verifying result from '{}'", - tool_name - ); - - if let Some(ref security) = extensions.security { - if let Some(ref subject) = security.subject { - println!( - " [identity-checker] Result authorized for subject: {:?}", - subject.id - ); - } - } - println!(" [identity-checker] POST-INVOKE ALLOWED"); - } else { - // PRE-INVOKE: check caller identity and roles - let tool_name = payload - .message - .get_tool_calls() - .first() - .map(|tc| tc.name.as_str()) - .unwrap_or("unknown"); - println!( - " [identity-checker] PRE-INVOKE: checking identity for '{}'", - tool_name - ); - - if let Some(ref security) = extensions.security { - let labels: Vec<&String> = security.labels.iter().collect(); - println!(" [identity-checker] Security labels: {:?}", labels); - - if let Some(ref subject) = security.subject { - println!( - " [identity-checker] Subject: {:?}, Roles: {:?}", - subject.id, - subject.roles.iter().collect::>() - ); - - if security.has_label("PII") && !subject.roles.contains("hr_admin") { - return PluginResult::deny(PluginViolation::new( - "insufficient_role", - format!("Tool '{}' requires 'hr_admin' role for PII data", tool_name), - )); - } - } - } - - if extensions.http.is_some() { - println!(" [identity-checker] WARNING: HTTP visible (unexpected!)"); - } else { - println!(" [identity-checker] HTTP: not visible (correct — no read_headers)"); - } - println!(" [identity-checker] PRE-INVOKE ALLOWED"); - } - - PluginResult::allow() - } -} - -// --------------------------------------------------------------------------- -// Plugin: HeaderInjector -// Has read_headers, write_headers, append_labels capabilities. -// Uses COW to add a security label and inject a header. -// --------------------------------------------------------------------------- - -struct HeaderInjector { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for HeaderInjector { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for HeaderInjector { - async fn handle( - &self, - _payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // Can see HTTP (has read_headers) - if let Some(ref http) = extensions.http { - println!( - " [header-injector] HTTP headers visible: {:?}", - http.request_headers - ); - } - - // Can NOT see security subject (no read_subject) - if let Some(ref security) = extensions.security { - if security.subject.is_some() { - println!(" [header-injector] WARNING: Subject visible (unexpected!)"); - } else { - println!(" [header-injector] Security subject: not visible (no read_subject)"); - } - } - - // COW copy to modify — tokens propagate from the executor - let mut modified = extensions.cow_copy(); - - // Add a label via MonotonicSet (has append_labels) - if modified.labels_write_token.is_some() { - modified.security.as_mut().unwrap().add_label("PROCESSED"); - println!(" [header-injector] Added label 'PROCESSED'"); - } - - // Inject a header via Guarded (has write_headers) - if let Some(ref token) = modified.http_write_token { - modified - .http - .as_mut() - .unwrap() - .write(token) - .set_header("X-Processed-By", "header-injector"); - println!(" [header-injector] Injected header 'X-Processed-By'"); - } - - PluginResult::modify_extensions(modified) - } -} - -// --------------------------------------------------------------------------- -// Plugin: AuditLogger -// Has read_headers, read_security, read_labels capabilities. -// Read-only — just logs what it can see. -// --------------------------------------------------------------------------- - -struct AuditLogger { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for AuditLogger { - async fn handle( - &self, - payload: &MessagePayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let is_result = payload.message.is_tool_result(); - let phase = if is_result { "POST" } else { "PRE" }; - - let tool_name = if is_result { - payload - .message - .get_tool_results() - .first() - .map(|tr| tr.tool_name.as_str()) - .unwrap_or("unknown") - } else { - payload - .message - .get_tool_calls() - .first() - .map(|tc| tc.name.as_str()) - .unwrap_or("unknown") - }; - - print!(" [audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name); - - if let Some(ref security) = extensions.security { - let labels: Vec<&String> = security.labels.iter().collect(); - print!("labels={:?} ", labels); - } - - if let Some(ref http) = extensions.http { - if let Some(req_id) = http.get_header("X-Request-ID") { - print!("request_id='{}' ", req_id); - } - } - - if is_result { - let is_error = payload - .message - .get_tool_results() - .first() - .map(|tr| tr.is_error) - .unwrap_or(false); - print!("error={} ", is_error); - } - - println!(); - PluginResult::allow() - } -} - -// --------------------------------------------------------------------------- -// Factories -// --------------------------------------------------------------------------- - -struct IdentityCheckerFactory; -impl PluginFactory for IdentityCheckerFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(IdentityChecker { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![ - ( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin.clone())), - ), - ( - "cmf.tool_post_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - ), - ], - }) - } -} - -struct HeaderInjectorFactory; -impl PluginFactory for HeaderInjectorFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(HeaderInjector { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -struct AuditLoggerFactory; -impl PluginFactory for AuditLoggerFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AuditLogger { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![ - ( - "cmf.tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin.clone())), - ), - ( - "cmf.tool_post_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - ), - ], - }) - } -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -#[tokio::main] -async fn main() { - println!("=== CMF Capabilities Demo ===\n"); - - // Load config from YAML file — capabilities declared per plugin - let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml"; - println!("--- Loading config from {} ---\n", config_path); - let yaml = std::fs::read_to_string(config_path) - .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); - let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); - - let mgr = PluginManager::default(); - mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory)); - mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory)); - mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // --- Build CMF Message --- - let payload = MessagePayload { - message: Message { - schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), - role: Role::Assistant, - content: vec![ - ContentPart::Text { - text: "Looking up compensation.".into(), - }, - ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".into(), - name: "get_compensation".into(), - arguments: [("employee_id".to_string(), serde_json::json!(42))].into(), - namespace: None, - }, - }, - ], - channel: None, - }, - }; - - // --- Build Extensions with security, HTTP, meta --- - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.add_label("HR_DATA"); - security.classification = Some("confidential".into()); - security.subject = Some(cpex_core::extensions::security::SubjectExtension { - id: Some("alice".into()), - subject_type: Some(cpex_core::extensions::security::SubjectType::User), - roles: ["hr_admin".to_string()].into(), - permissions: ["read_compensation".to_string()].into(), - ..Default::default() - }); - - let mut http = HttpExtension::default(); - http.set_header("Authorization", "Bearer eyJ..."); - http.set_header("X-Request-ID", "req-abc-123"); - - let ext = Extensions { - request: Some(Arc::new(RequestExtension { - environment: Some("production".into()), - request_id: Some("req-abc-123".into()), - ..Default::default() - })), - security: Some(Arc::new(security)), - http: Some(Arc::new(http)), - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - tags: ["pii".to_string(), "hr".to_string()].into(), - ..Default::default() - })), - ..Default::default() - }; - - // --- Pre-invoke: type-safe dispatch via invoke_named --- - println!("=== Phase 1: cmf.tool_pre_invoke ===\n"); - - // invoke_named gives compile-time payload type checking - // while routing to the specific "cmf.tool_pre_invoke" hook name - let (pre_result, bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - payload, - ext, - None, // first hook — no context table - ) - .await; - - println!(); - if pre_result.continue_processing { - println!("Pre-invoke result: ALLOWED"); - if let Some(ref modified_ext) = pre_result.modified_extensions { - if let Some(ref sec) = modified_ext.security { - let labels: Vec<&String> = sec.labels.iter().collect(); - println!(" Labels after pre-invoke: {:?}", labels); - } - if let Some(ref http) = modified_ext.http { - println!(" Headers after pre-invoke: {:?}", http.request_headers); - } - } - } else { - println!( - "Pre-invoke result: DENIED — {}", - pre_result.violation.as_ref().unwrap().reason - ); - bg.wait_for_background_tasks().await; - println!("\n=== Demo complete ==="); - return; - } - bg.wait_for_background_tasks().await; - - // --- Simulate tool execution --- - println!("\n--- Tool 'get_compensation' executes... ---"); - println!(" Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n"); - - // --- Post-invoke: different CMF message with tool result --- - println!("=== Phase 2: cmf.tool_post_invoke ===\n"); - - let post_payload = MessagePayload { - message: Message { - schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), - role: Role::Tool, - content: vec![ContentPart::ToolResult { - content: cpex_core::cmf::ToolResult { - tool_call_id: "tc_001".into(), - tool_name: "get_compensation".into(), - content: serde_json::json!({"salary": 150000, "currency": "USD"}), - is_error: false, - }, - }], - channel: None, - }, - }; - - // Build post-invoke extensions — carry forward any modifications - // from pre-invoke via the context table - let post_ext = pre_result.modified_extensions.unwrap_or_else(|| { - // Rebuild if no modifications - let mut security = SecurityExtension::default(); - security.add_label("PII"); - Extensions { - security: Some(Arc::new(security)), - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - } - }); - - // Thread the context table from pre-invoke to preserve plugin state - let (post_result, post_bg) = mgr - .invoke_named::( - "cmf.tool_post_invoke", - post_payload, - post_ext, - Some(pre_result.context_table), - ) - .await; - - println!(); - if post_result.continue_processing { - println!("Post-invoke result: ALLOWED"); - } else { - println!( - "Post-invoke result: DENIED — {}", - post_result.violation.as_ref().unwrap().reason - ); - } - - post_bg.wait_for_background_tasks().await; - println!("\n=== Demo complete ==="); -} diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.yaml b/crates/cpex-core/examples/cmf_capabilities_demo.yaml deleted file mode 100644 index ace0f5cb..00000000 --- a/crates/cpex-core/examples/cmf_capabilities_demo.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# CMF Capabilities Demo Configuration -# -# Three plugins with different capabilities see different views -# of the same extensions. Demonstrates capability-gated access -# across pre-invoke and post-invoke hooks. - -plugin_settings: - routing_enabled: true - -global: - policies: - all: - plugins: [identity-checker, header-injector, audit-logger] - -plugins: - - name: identity-checker - kind: builtin/identity-checker - hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] - mode: sequential - priority: 10 - on_error: fail - capabilities: - - read_labels - - read_subject - - read_roles - - - name: header-injector - kind: builtin/header-injector - hooks: [cmf.tool_pre_invoke] - mode: sequential - priority: 20 - on_error: fail - capabilities: - - read_headers - - write_headers - - append_labels - - - name: audit-logger - kind: builtin/audit-logger - hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] - mode: audit - priority: 100 - on_error: ignore - capabilities: - - read_headers - - read_labels - -routes: - - tool: "*" - plugins: [] diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs deleted file mode 100644 index 12cfd3f5..00000000 --- a/crates/cpex-core/examples/plugin_demo.rs +++ /dev/null @@ -1,556 +0,0 @@ -// CPEX Plugin Demo -// -// Demonstrates how to: -// 1. Define hook types and payloads -// 2. Build plugins that implement HookHandler -// 3. Create plugin factories for config-driven loading -// 4. Load a YAML config with routing rules -// 5. Invoke hooks with MetaExtension for route resolution -// -// Run with: cargo run --example plugin_demo - -use std::sync::Arc; - -use async_trait::async_trait; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::executor::PipelineResult; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::{Extensions, MetaExtension}; -use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; - -// --------------------------------------------------------------------------- -// Step 1: Define a payload and hook type -// --------------------------------------------------------------------------- - -/// The payload carried through the tool_pre_invoke hook. -#[derive(Debug, Clone)] -struct ToolInvokePayload { - tool_name: String, - user: String, - arguments: String, -} -cpex_core::impl_plugin_payload!(ToolInvokePayload); - -/// Hook type for tool_pre_invoke — runs before a tool executes. -struct ToolPreInvoke; -impl HookTypeDef for ToolPreInvoke { - type Payload = ToolInvokePayload; - type Result = PluginResult; - const NAME: &'static str = "tool_pre_invoke"; -} - -/// Hook type for tool_post_invoke — runs after a tool executes. -struct ToolPostInvoke; -impl HookTypeDef for ToolPostInvoke { - type Payload = ToolInvokePayload; - type Result = PluginResult; - const NAME: &'static str = "tool_post_invoke"; -} - -// --------------------------------------------------------------------------- -// Step 2: Build plugins -// --------------------------------------------------------------------------- - -/// Identity resolver — checks that a user is present. -struct IdentityResolver { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for IdentityResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - println!(" [identity-resolver] initialized"); - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - println!(" [identity-resolver] shutdown"); - Ok(()) - } -} - -impl HookHandler for IdentityResolver { - async fn handle( - &self, - payload: &ToolInvokePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - if payload.user.is_empty() { - println!(" [identity-resolver] DENIED: no user identity"); - return PluginResult::deny(PluginViolation::new( - "no_identity", - "User identity is required", - )); - } - println!( - " [identity-resolver] OK: user '{}' identified", - payload.user - ); - PluginResult::allow() - } -} - -impl HookHandler for IdentityResolver { - async fn handle( - &self, - payload: &ToolInvokePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - println!( - " [identity-resolver] post-invoke: user '{}' completed '{}'", - payload.user, payload.tool_name - ); - PluginResult::allow() - } -} - -/// PII guard — blocks access to sensitive tools without clearance. -struct PiiGuard { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for PiiGuard { - fn config(&self) -> &PluginConfig { - &self.cfg - } - // initialize() and shutdown() use defaults — no setup needed -} - -impl HookHandler for PiiGuard { - async fn handle( - &self, - payload: &ToolInvokePayload, - _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> PluginResult { - // Check if the user has PII clearance (simulated via context) - let has_clearance = ctx - .get_global("pii_clearance") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if !has_clearance { - println!( - " [pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", - payload.user, payload.tool_name - ); - return PluginResult::deny(PluginViolation::new( - "pii_access_denied", - "PII clearance required", - )); - } - - println!( - " [pii-guard] OK: user '{}' has PII clearance", - payload.user - ); - PluginResult::allow() - } -} - -/// Audit logger — logs all tool invocations (fire-and-forget). -struct AuditLogger { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { - &self.cfg - } - // initialize() and shutdown() use defaults — no setup needed -} - -impl HookHandler for AuditLogger { - async fn handle( - &self, - payload: &ToolInvokePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - println!( - " [audit-logger] LOG: user='{}' tool='{}' args='{}'", - payload.user, payload.tool_name, payload.arguments - ); - PluginResult::allow() - } -} - -impl HookHandler for AuditLogger { - async fn handle( - &self, - payload: &ToolInvokePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - println!( - " [audit-logger] LOG: post-invoke user='{}' tool='{}'", - payload.user, payload.tool_name - ); - PluginResult::allow() - } -} - -// --------------------------------------------------------------------------- -// Awaiting plugin example — RemoteAuthz -// --------------------------------------------------------------------------- -// -// `HookHandler` is async by design — `handle` is `async fn`. -// Plugins that don't need to `.await` anything still write -// `async fn handle` and return synchronously; this plugin shows the -// other direction, where the body genuinely awaits per-invocation -// work. The realistic version would call a remote authz service -// (gRPC, HTTP, OPA, Cedarling, etc.); here we simulate the network -// round-trip with a small `tokio::time::sleep` so the demo runs -// offline. -// -// Key things this shows: -// 1. Per-request latency state is *cached at init* — the handler -// consults the in-memory ACL and only "calls out" on a miss. -// Hot-path I/O is the most common source of latency regressions -// in plugins, so prefer initialize-time loading wherever you can. -// 2. Registration uses the exact same factory pattern as any other -// plugin — `TypedHandlerAdapter::` and the same -// `register_factory` call. There is no separate async path. -struct RemoteAuthz { - cfg: PluginConfig, - /// ACL "fetched" at init. Populated in Plugin::initialize. - allowed_users: tokio::sync::RwLock>, -} - -#[async_trait] -impl Plugin for RemoteAuthz { - fn config(&self) -> &PluginConfig { - &self.cfg - } - /// Pretend we're loading the ACL from a remote service. In a real - /// plugin this would be `client.fetch_acl().await`; we simulate - /// the round-trip with a small sleep so the demo runs offline. - async fn initialize(&self) -> Result<(), Box> { - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let mut acl = self.allowed_users.write().await; - acl.extend(["alice", "bob"].iter().map(|s| s.to_string())); - println!( - " [remote-authz] initialized — ACL cached ({} users)", - acl.len() - ); - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - println!(" [remote-authz] shutdown"); - Ok(()) - } -} - -impl HookHandler for RemoteAuthz { - async fn handle( - &self, - payload: &ToolInvokePayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // Cache hit path — fast. - let acl = self.allowed_users.read().await; - if acl.contains(&payload.user) { - println!( - " [remote-authz] OK (cache hit): user '{}' allowed", - payload.user - ); - return PluginResult::allow(); - } - drop(acl); // release read lock before the fake remote call - // Cache miss path — simulate a remote authz check. In a real - // plugin this is where you'd `.await` a gRPC or HTTP call. - // The latency cost is real and shows up on the request path. - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - println!( - " [remote-authz] DENIED (cache miss + remote check): user '{}'", - payload.user - ); - PluginResult::deny(PluginViolation::new( - "remote_authz_denied", - format!("User '{}' not in remote ACL", payload.user), - )) - } -} - -// --------------------------------------------------------------------------- -// Step 3: Create plugin factories -// --------------------------------------------------------------------------- - -struct IdentityFactory; -impl PluginFactory for IdentityFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(IdentityResolver { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![ - ( - "tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin.clone())), - ), - ( - "tool_post_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - ), - ], - }) - } -} - -struct PiiGuardFactory; -impl PluginFactory for PiiGuardFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(PiiGuard { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -struct AuditLoggerFactory; -impl PluginFactory for AuditLoggerFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AuditLogger { - cfg: config.clone(), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![ - ( - "tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin.clone())), - ), - ( - "tool_post_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - ), - ], - }) - } -} - -/// Factory for the async plugin. Note the factory body is identical -/// in shape to the sync factories above — `TypedHandlerAdapter` and -/// the `register_factory` path don't care that the underlying handler -/// is async. The framework hides the choice. -struct RemoteAuthzFactory; -impl PluginFactory for RemoteAuthzFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(RemoteAuthz { - cfg: config.clone(), - allowed_users: tokio::sync::RwLock::new(std::collections::HashSet::new()), - }); - Ok(PluginInstance { - plugin: plugin.clone(), - handlers: vec![( - "tool_pre_invoke", - Arc::new(TypedHandlerAdapter::::new(plugin)), - )], - }) - } -} - -// --------------------------------------------------------------------------- -// Step 4: Build extensions with MetaExtension for routing -// --------------------------------------------------------------------------- - -fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { - Extensions { - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some(tool_name.into()), - tags: tags.iter().map(|s| s.to_string()).collect(), - ..Default::default() - })), - ..Default::default() - } -} - -// --------------------------------------------------------------------------- -// Helper to print results -// --------------------------------------------------------------------------- - -fn print_result(_label: &str, result: &PipelineResult) { - if result.continue_processing { - println!(" Result: ALLOWED"); - } else { - let violation = result.violation.as_ref().unwrap(); - println!( - " Result: DENIED by '{}' — {} [{}]", - violation.plugin_name.as_deref().unwrap_or("unknown"), - violation.reason, - violation.code, - ); - } - println!(); -} - -// --------------------------------------------------------------------------- -// Step 5: Main — load config, invoke hooks, see results -// --------------------------------------------------------------------------- - -#[tokio::main] -async fn main() { - println!("=== CPEX Plugin Demo ===\n"); - - // --- Load config from YAML file --- - let config_path = "crates/cpex-core/examples/plugin_demo.yaml"; - println!("--- Loading config from {} ---\n", config_path); - let yaml = std::fs::read_to_string(config_path) - .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); - let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); - - let mgr = PluginManager::default(); - mgr.register_factory("builtin/identity", Box::new(IdentityFactory)); - mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory)); - mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); - mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory)); - mgr.load_config(cpex_config).unwrap(); - - println!("\n--- Initializing plugins ---\n"); - mgr.initialize().await.unwrap(); - - println!("\nPlugins loaded: {}", mgr.plugin_count()); - println!( - "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", - mgr.has_hooks_for("tool_pre_invoke"), - mgr.has_hooks_for("tool_post_invoke"), - ); - - // --- Scenario 1: PII tool without clearance --- - println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n"); - let payload = ToolInvokePayload { - tool_name: "get_compensation".into(), - user: "alice".into(), - arguments: "employee_id=42".into(), - }; - let ext = make_tool_extensions("get_compensation", &[]); - let (result, bg) = mgr.invoke::(payload, ext, None).await; - print_result("get_compensation (no clearance)", &result); - // Wait for any fire-and-forget tasks - bg.wait_for_background_tasks().await; - - // --- Scenario 2: PII tool with clearance --- - println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n"); - let payload = ToolInvokePayload { - tool_name: "get_compensation".into(), - user: "alice".into(), - arguments: "employee_id=42".into(), - }; - let ext = make_tool_extensions("get_compensation", &[]); - // Simulate clearance by pre-populating global_state - // (In production, an earlier hook would set this from a token claim) - let mut ctx_table = cpex_core::context::PluginContextTable::new(); - ctx_table - .global_state - .insert("pii_clearance".into(), serde_json::Value::Bool(true)); - let (result, bg) = mgr - .invoke::(payload, ext, Some(ctx_table)) - .await; - print_result("get_compensation (with clearance)", &result); - bg.wait_for_background_tasks().await; - - // Now call post-invoke — threads the context table from pre-invoke - println!(" --- post-invoke for get_compensation ---\n"); - let payload = ToolInvokePayload { - tool_name: "get_compensation".into(), - user: "alice".into(), - arguments: "employee_id=42".into(), - }; - let ext = make_tool_extensions("get_compensation", &[]); - let (post_result, bg) = mgr - .invoke::(payload, ext, Some(result.context_table)) - .await; - print_result("get_compensation post-invoke", &post_result); - bg.wait_for_background_tasks().await; - - // --- Scenario 3: Non-PII tool --- - println!("=== Scenario 3: list_departments (non-PII tool) ===\n"); - let payload = ToolInvokePayload { - tool_name: "list_departments".into(), - user: "bob".into(), - arguments: "".into(), - }; - let ext = make_tool_extensions("list_departments", &[]); - let (result, bg) = mgr.invoke::(payload, ext, None).await; - print_result("list_departments", &result); - bg.wait_for_background_tasks().await; - - // --- Scenario 4: Unknown tool (wildcard route) --- - println!("=== Scenario 4: some_other_tool (wildcard route) ===\n"); - let payload = ToolInvokePayload { - tool_name: "some_other_tool".into(), - user: "charlie".into(), - arguments: "foo=bar".into(), - }; - let ext = make_tool_extensions("some_other_tool", &[]); - let (result, bg) = mgr.invoke::(payload, ext, None).await; - print_result("some_other_tool (wildcard)", &result); - bg.wait_for_background_tasks().await; - - // --- Scenario 5: Awaiting plugin — cache hit --- - // RemoteAuthz's `handle` is `async fn` and reads from a tokio - // RwLock. Its initialize() pre-loaded an ACL containing "alice" - // and "bob"; this call exercises the cache-hit fast path. - println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n"); - let payload = ToolInvokePayload { - tool_name: "query_external_data".into(), - user: "alice".into(), - arguments: "dataset=sales".into(), - }; - let ext = make_tool_extensions("query_external_data", &[]); - let (result, bg) = mgr.invoke::(payload, ext, None).await; - print_result("query_external_data (alice — in ACL)", &result); - bg.wait_for_background_tasks().await; - - // --- Scenario 6: Awaiting plugin — cache miss path with .await --- - // "charlie" is not in the cached ACL, so RemoteAuthz takes the - // cache-miss branch and `.await`s a simulated remote call before - // denying. - println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n"); - let payload = ToolInvokePayload { - tool_name: "query_external_data".into(), - user: "charlie".into(), - arguments: "dataset=sales".into(), - }; - let ext = make_tool_extensions("query_external_data", &[]); - let (result, bg) = mgr.invoke::(payload, ext, None).await; - print_result("query_external_data (charlie — not in ACL)", &result); - bg.wait_for_background_tasks().await; - - // --- Scenario 7: No user identity --- - println!("=== Scenario 7: list_departments (no user identity) ===\n"); - let payload = ToolInvokePayload { - tool_name: "list_departments".into(), - user: "".into(), - arguments: "".into(), - }; - let ext = make_tool_extensions("list_departments", &[]); - let (result, bg) = mgr.invoke::(payload, ext, None).await; - print_result("list_departments (no user)", &result); - bg.wait_for_background_tasks().await; - - // --- Shutdown --- - println!("--- Shutting down ---\n"); - mgr.shutdown().await; - - println!("=== Demo complete ==="); -} diff --git a/crates/cpex-core/examples/plugin_demo.yaml b/crates/cpex-core/examples/plugin_demo.yaml deleted file mode 100644 index 07051e95..00000000 --- a/crates/cpex-core/examples/plugin_demo.yaml +++ /dev/null @@ -1,84 +0,0 @@ -# CPEX Plugin Demo Configuration -# -# Three plugins, policy groups with tag-based activation, -# and routes that map tools to different plugin combinations. - -plugin_settings: - routing_enabled: true - plugin_timeout: 30 - -global: - policies: - # "all" is reserved — these plugins fire on every invocation - all: - plugins: [identity-resolver] - # "pii" group — activated when a route has the "pii" tag - pii: - plugins: [pii-guard] - # "external_authz" group — activated when a route has the - # "needs_remote_authz" tag. Fires the async RemoteAuthz plugin. - external_authz: - plugins: [remote-authz] - -plugins: - - name: identity-resolver - kind: builtin/identity - hooks: [tool_pre_invoke, tool_post_invoke] - mode: sequential - priority: 10 - on_error: fail - - - name: pii-guard - kind: builtin/pii - hooks: [tool_pre_invoke] - mode: sequential - priority: 20 - on_error: fail - config: - clearance_level: confidential - - # Awaiting plugin — its `handle` body uses `.await` for a - # (simulated) remote authz call on cache miss. Wired in exactly - # the same way as plugins whose `handle` body has no `.await`; - # registration is identical either way. - - name: remote-authz - kind: builtin/remote_authz - hooks: [tool_pre_invoke] - mode: sequential - priority: 30 - on_error: fail - - - name: audit-logger - kind: builtin/audit - hooks: [tool_pre_invoke, tool_post_invoke] - mode: fire_and_forget - priority: 100 - on_error: ignore - -routes: - # HR compensation tool — contains PII, gets full security stack - - tool: get_compensation - meta: - tags: [pii, hr] - plugins: - - audit-logger - - # Public department listing — standard security only - - tool: list_departments - plugins: - - audit-logger - - # Tool that requires remote authz — triggers the async plugin - # on top of the standard "all" policy stack. The "external_authz" - # tag matches the policy group of the same name, which fires - # remote-authz. - - tool: query_external_data - meta: - tags: [external_authz] - plugins: - - audit-logger - - # Wildcard — catch-all for unmatched tools - - tool: "*" - plugins: - - audit-logger diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs deleted file mode 100644 index 185b85c1..00000000 --- a/crates/cpex-core/src/cmf/constants.rs +++ /dev/null @@ -1,101 +0,0 @@ -// Location: ./crates/cpex-core/src/cmf/constants.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CMF constants — schema version, serialization field names, and defaults. - -/// Current CMF message schema version. -pub const SCHEMA_VERSION: &str = "2.0"; - -// Core view fields -pub const FIELD_KIND: &str = "kind"; -pub const FIELD_ROLE: &str = "role"; -pub const FIELD_IS_PRE: &str = "is_pre"; -pub const FIELD_IS_POST: &str = "is_post"; -pub const FIELD_ACTION: &str = "action"; -pub const FIELD_HOOK: &str = "hook"; -pub const FIELD_URI: &str = "uri"; -pub const FIELD_NAME: &str = "name"; -pub const FIELD_CONTENT: &str = "content"; -pub const FIELD_SIZE_BYTES: &str = "size_bytes"; -pub const FIELD_MIME_TYPE: &str = "mime_type"; -pub const FIELD_ARGUMENTS: &str = "arguments"; - -// Extensions container -pub const FIELD_EXTENSIONS: &str = "extensions"; - -// Subject fields -pub const FIELD_SUBJECT: &str = "subject"; -pub const FIELD_ID: &str = "id"; -pub const FIELD_TYPE: &str = "type"; -pub const FIELD_ROLES: &str = "roles"; -pub const FIELD_PERMISSIONS: &str = "permissions"; -pub const FIELD_TEAMS: &str = "teams"; - -// Security fields -pub const FIELD_LABELS: &str = "labels"; - -// Request fields -pub const FIELD_ENVIRONMENT: &str = "environment"; - -// HTTP fields -pub const FIELD_HEADERS: &str = "headers"; - -// Agent fields -pub const FIELD_AGENT: &str = "agent"; -pub const FIELD_INPUT: &str = "input"; -pub const FIELD_SESSION_ID: &str = "session_id"; -pub const FIELD_CONVERSATION_ID: &str = "conversation_id"; -pub const FIELD_TURN: &str = "turn"; -pub const FIELD_AGENT_ID: &str = "agent_id"; -pub const FIELD_PARENT_AGENT_ID: &str = "parent_agent_id"; - -// Meta fields -pub const FIELD_META: &str = "meta"; -pub const FIELD_ENTITY_TYPE: &str = "entity_type"; -pub const FIELD_ENTITY_NAME: &str = "entity_name"; -pub const FIELD_TAGS: &str = "tags"; - -// OPA envelope -pub const FIELD_OPA_INPUT: &str = "input"; - -// Entity type identifiers — used in MetaExtension.entity_type and as the -// keys for `global.defaults` per-entity-type policy groups. These are the -// MCP entity taxonomy: tools (callable functions), LLMs (model -// invocations), prompts (template fills), resources (URI fetches). -pub const ENTITY_TOOL: &str = "tool"; -pub const ENTITY_LLM: &str = "llm"; -pub const ENTITY_PROMPT: &str = "prompt"; -pub const ENTITY_RESOURCE: &str = "resource"; - -/// Reserved entity type for generic (non-MCP/A2A) HTTP requests. The -/// catch-all `global` policy is dispatched under this entity so an -/// entity-less request can be authorized; hosts set `meta.entity_type` to -/// this and `meta.entity_name` to [`ENTITY_NAME_GLOBAL`]. -pub const ENTITY_HTTP: &str = "http"; - -/// Reserved entity name for the global catch-all policy annotation. -pub const ENTITY_NAME_GLOBAL: &str = "*"; - -// CMF hook names — the canonical names plugins register under and hosts -// pass to `PluginManager::invoke_named::(...)`. Two per entity -// type — pre-invocation (called from APL's policy / args phase) and -// post-invocation (called from APL's post_policy / result phase). -// -// Used as keys in `hooks::metadata`'s routing table and from plugin -// declarations. -pub const HOOK_CMF_TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; -pub const HOOK_CMF_TOOL_POST_INVOKE: &str = "cmf.tool_post_invoke"; -pub const HOOK_CMF_LLM_INPUT: &str = "cmf.llm_input"; -pub const HOOK_CMF_LLM_OUTPUT: &str = "cmf.llm_output"; -pub const HOOK_CMF_PROMPT_PRE_INVOKE: &str = "cmf.prompt_pre_invoke"; -pub const HOOK_CMF_PROMPT_POST_INVOKE: &str = "cmf.prompt_post_invoke"; -pub const HOOK_CMF_RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; -pub const HOOK_CMF_RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; - -/// Generic HTTP request hook. Hosts fire this for non-MCP/A2A HTTP -/// requests; the catch-all `global` policy (if any) is annotated under -/// it via [`ENTITY_HTTP`] / [`ENTITY_NAME_GLOBAL`]. Pre-invocation only — -/// authorization is an admission check, so there is no post counterpart. -pub const HOOK_CMF_HTTP_REQUEST: &str = "cmf.http_request"; diff --git a/crates/cpex-core/src/cmf/content.rs b/crates/cpex-core/src/cmf/content.rs deleted file mode 100644 index 938862de..00000000 --- a/crates/cpex-core/src/cmf/content.rs +++ /dev/null @@ -1,474 +0,0 @@ -// Location: ./crates/cpex-core/src/cmf/content.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CMF domain objects and ContentPart hierarchy. -// -// Domain objects (ToolCall, Resource, etc.) are standalone structs -// reusable outside of message content parts. ContentPart is a tagged -// enum that wraps them for message serialization. -// -// Mirrors the Python types in cpex/framework/cmf/message.py. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use super::enums::ResourceType; -use super::message::Message; - -/// Normalized tool/function invocation request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - /// Unique request correlation ID. - pub tool_call_id: String, - /// Tool name. - pub name: String, - /// Arguments as a JSON-serializable map. - #[serde(default)] - pub arguments: HashMap, - /// Optional namespace for namespaced tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub namespace: Option, -} - -/// Result from tool execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolResult { - /// Correlation ID linking to the corresponding tool call. - pub tool_call_id: String, - /// Name of the tool that was executed. - pub tool_name: String, - /// Result content (any JSON-serializable value). - #[serde(default)] - pub content: serde_json::Value, - /// Whether the result represents an error. - #[serde(default)] - pub is_error: bool, -} - -/// Embedded resource with content (MCP). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Resource { - /// Unique request correlation ID. - pub resource_request_id: String, - /// Unique identifier in URI format. - pub uri: String, - /// Human-readable name. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// What this resource contains. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// The kind of resource. - pub resource_type: ResourceType, - /// Text content if embedded. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub content: Option, - /// Binary content if embedded (base64 in JSON). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub blob: Option>, - /// MIME type of content. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - /// Size information. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size_bytes: Option, - /// Metadata (classification, retention, etc.). - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub annotations: HashMap, - /// Version tracking. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -impl Resource { - /// Whether content or blob is embedded. - pub fn is_embedded(&self) -> bool { - self.content.is_some() || self.blob.is_some() - } - - /// Get text content if available. - pub fn get_text_content(&self) -> Option<&str> { - self.content.as_deref() - } -} - -/// Lightweight resource reference without embedded content. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourceReference { - /// Correlation ID linking to the originating resource request. - pub resource_request_id: String, - /// Resource URI. - pub uri: String, - /// Human-readable name. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Type of resource. - pub resource_type: ResourceType, - /// Line number or byte offset for partial references. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_start: Option, - /// End of range. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_end: Option, - /// CSS/XPath/JSONPath selector. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selector: Option, -} - -/// Prompt template invocation request (MCP). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PromptRequest { - /// Request ID for correlation. - pub prompt_request_id: String, - /// Prompt template name. - pub name: String, - /// Arguments to pass to the template. - #[serde(default)] - pub arguments: HashMap, - /// Source server for multi-server scenarios. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_id: Option, -} - -/// Rendered prompt template result. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PromptResult { - /// ID of the corresponding prompt request. - pub prompt_request_id: String, - /// Name of the prompt that was rendered. - pub prompt_name: String, - /// Rendered messages (prompts produce messages). - #[serde(default)] - pub messages: Vec, - /// Single text result for simple prompts. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub content: Option, - /// Whether rendering failed. - #[serde(default)] - pub is_error: bool, - /// Error details if rendering failed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_message: Option, -} - -/// Image source data. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageSource { - /// Source type: "url" or "base64". - #[serde(rename = "type")] - pub source_type: String, - /// URL or base64-encoded string. - pub data: String, - /// MIME type (e.g., image/jpeg). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub media_type: Option, -} - -/// Video source data. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VideoSource { - /// Source type: "url" or "base64". - #[serde(rename = "type")] - pub source_type: String, - /// URL or base64-encoded string. - pub data: String, - /// MIME type (e.g., video/mp4). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub media_type: Option, - /// Duration in milliseconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration_ms: Option, -} - -/// Audio source data. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioSource { - /// Source type: "url" or "base64". - #[serde(rename = "type")] - pub source_type: String, - /// URL or base64-encoded string. - pub data: String, - /// MIME type (e.g., audio/mp3). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub media_type: Option, - /// Duration in milliseconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration_ms: Option, -} - -/// Document source data (PDF, Word, etc.). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DocumentSource { - /// Source type: "url" or "base64". - #[serde(rename = "type")] - pub source_type: String, - /// URL or base64-encoded string. - pub data: String, - /// MIME type (e.g., application/pdf). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub media_type: Option, - /// Document title. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, -} - -/// A typed content part in a CMF message. -/// -/// Discriminated by the `content_type` field. Each variant wraps -/// either a text string or a domain object. -/// -/// Mirrors the Python `ContentPartUnion` discriminated union in -/// `cpex/framework/cmf/message.py`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "content_type")] -pub enum ContentPart { - /// Plain text content. - #[serde(rename = "text")] - Text { text: String }, - - /// Chain-of-thought reasoning. - #[serde(rename = "thinking")] - Thinking { text: String }, - - /// Tool/function invocation request. - #[serde(rename = "tool_call")] - ToolCall { content: ToolCall }, - - /// Result from tool execution. - #[serde(rename = "tool_result")] - ToolResult { content: ToolResult }, - - /// Embedded resource with content. - #[serde(rename = "resource")] - Resource { content: Resource }, - - /// Lightweight resource reference. - #[serde(rename = "resource_ref")] - ResourceRef { content: ResourceReference }, - - /// Prompt template invocation request. - #[serde(rename = "prompt_request")] - PromptRequest { content: PromptRequest }, - - /// Rendered prompt template result. - #[serde(rename = "prompt_result")] - PromptResult { content: PromptResult }, - - /// Image content. - #[serde(rename = "image")] - Image { content: ImageSource }, - - /// Video content. - #[serde(rename = "video")] - Video { content: VideoSource }, - - /// Audio content. - #[serde(rename = "audio")] - Audio { content: AudioSource }, - - /// Document content. - #[serde(rename = "document")] - Document { content: DocumentSource }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_text_content_part_serde() { - let json = r#"{"content_type":"text","text":"Hello, world!"}"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::Text { text } => assert_eq!(text, "Hello, world!"), - _ => panic!("expected Text variant"), - } - let roundtrip = serde_json::to_string(&part).unwrap(); - let part2: ContentPart = serde_json::from_str(&roundtrip).unwrap(); - match part2 { - ContentPart::Text { text } => assert_eq!(text, "Hello, world!"), - _ => panic!("expected Text variant"), - } - } - - #[test] - fn test_tool_call_content_part_serde() { - let json = r#"{ - "content_type": "tool_call", - "content": { - "tool_call_id": "tc_001", - "name": "get_weather", - "arguments": {"city": "London"} - } - }"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::ToolCall { content } => { - assert_eq!(content.name, "get_weather"); - assert_eq!(content.tool_call_id, "tc_001"); - assert_eq!(content.arguments["city"], "London"); - }, - _ => panic!("expected ToolCall variant"), - } - } - - #[test] - fn test_tool_result_content_part_serde() { - let json = r#"{ - "content_type": "tool_result", - "content": { - "tool_call_id": "tc_001", - "tool_name": "get_weather", - "content": {"temp": 20, "unit": "C"}, - "is_error": false - } - }"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::ToolResult { content } => { - assert_eq!(content.tool_name, "get_weather"); - assert!(!content.is_error); - }, - _ => panic!("expected ToolResult variant"), - } - } - - #[test] - fn test_resource_content_part_serde() { - let json = r#"{ - "content_type": "resource", - "content": { - "resource_request_id": "rr_001", - "uri": "file:///data.txt", - "resource_type": "file", - "content": "Hello from file" - } - }"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::Resource { content } => { - assert_eq!(content.uri, "file:///data.txt"); - assert!(content.is_embedded()); - assert_eq!(content.get_text_content(), Some("Hello from file")); - }, - _ => panic!("expected Resource variant"), - } - } - - #[test] - fn test_resource_ref_content_part_serde() { - let json = r#"{ - "content_type": "resource_ref", - "content": { - "resource_request_id": "rr_002", - "uri": "db://users/42", - "resource_type": "database" - } - }"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::ResourceRef { content } => { - assert_eq!(content.uri, "db://users/42"); - assert_eq!(content.resource_type, ResourceType::Database); - }, - _ => panic!("expected ResourceRef variant"), - } - } - - #[test] - fn test_image_content_part_serde() { - let json = r#"{ - "content_type": "image", - "content": { - "type": "url", - "data": "https://example.com/photo.jpg", - "media_type": "image/jpeg" - } - }"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::Image { content } => { - assert_eq!(content.source_type, "url"); - assert_eq!(content.data, "https://example.com/photo.jpg"); - }, - _ => panic!("expected Image variant"), - } - } - - #[test] - fn test_prompt_request_content_part_serde() { - let json = r#"{ - "content_type": "prompt_request", - "content": { - "prompt_request_id": "pr_001", - "name": "summarize", - "arguments": {"text": "Long document..."} - } - }"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::PromptRequest { content } => { - assert_eq!(content.name, "summarize"); - }, - _ => panic!("expected PromptRequest variant"), - } - } - - #[test] - fn test_thinking_content_part_serde() { - let json = r#"{"content_type":"thinking","text":"Let me analyze..."}"#; - let part: ContentPart = serde_json::from_str(json).unwrap(); - match &part { - ContentPart::Thinking { text } => assert_eq!(text, "Let me analyze..."), - _ => panic!("expected Thinking variant"), - } - } - - #[test] - fn test_tool_call_construction() { - let tc = ToolCall { - tool_call_id: "tc_001".into(), - name: "search".into(), - arguments: [("query".to_string(), serde_json::json!("rust"))].into(), - namespace: None, - }; - assert_eq!(tc.name, "search"); - assert_eq!(tc.arguments["query"], "rust"); - } - - #[test] - fn test_resource_is_embedded() { - let embedded = Resource { - resource_request_id: "rr_001".into(), - uri: "file:///data.txt".into(), - name: None, - description: None, - resource_type: ResourceType::File, - content: Some("data".into()), - blob: None, - mime_type: None, - size_bytes: None, - annotations: HashMap::new(), - version: None, - }; - assert!(embedded.is_embedded()); - - let not_embedded = Resource { - resource_request_id: "rr_002".into(), - uri: "file:///other.txt".into(), - name: None, - description: None, - resource_type: ResourceType::File, - content: None, - blob: None, - mime_type: None, - size_bytes: None, - annotations: HashMap::new(), - version: None, - }; - assert!(!not_embedded.is_embedded()); - } -} diff --git a/crates/cpex-core/src/cmf/enums.rs b/crates/cpex-core/src/cmf/enums.rs deleted file mode 100644 index e976e582..00000000 --- a/crates/cpex-core/src/cmf/enums.rs +++ /dev/null @@ -1,160 +0,0 @@ -// Location: ./crates/cpex-core/src/cmf/enums.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CMF enums — Role, Channel, ContentType, ResourceType. -// -// Mirrors the Python enums in cpex/framework/cmf/message.py. -// All use snake_case serialization to match Python string values. - -use serde::{Deserialize, Serialize}; - -/// Identifies WHO is speaking in a conversation turn. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Role { - /// System-level instructions. - System, - /// Developer-provided instructions. - Developer, - /// Human user input. - User, - /// LLM/agent response. - Assistant, - /// Tool execution result. - Tool, -} - -/// Classifies the kind of output a message represents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Channel { - /// Intermediate analytical output (chain-of-thought). - Analysis, - /// Meta-level observations about the task. - Commentary, - /// Terminal response intended for delivery. - Final, -} - -/// Discriminator for the typed ContentPart hierarchy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContentType { - /// Plain text content. - Text, - /// Chain-of-thought reasoning. - Thinking, - /// Tool/function invocation request. - ToolCall, - /// Result from tool execution. - ToolResult, - /// Embedded resource with content (MCP). - Resource, - /// Lightweight resource reference without embedded content. - ResourceRef, - /// Prompt template invocation request (MCP). - PromptRequest, - /// Rendered prompt template result. - PromptResult, - /// Image content (URL or base64). - Image, - /// Video content (URL or base64). - Video, - /// Audio content (URL or base64). - Audio, - /// Document content (PDF, Word, etc.). - Document, -} - -/// Type of resource being referenced. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ResourceType { - /// File-system resource. - #[default] - File, - /// Binary large object. - Blob, - /// Generic URI-addressable resource. - Uri, - /// Database entity. - Database, - /// API endpoint. - Api, - /// In-memory or ephemeral resource. - Memory, - /// Produced artifact (generated output, build result). - Artifact, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_role_serde_roundtrip() { - let role = Role::Assistant; - let json = serde_json::to_string(&role).unwrap(); - assert_eq!(json, "\"assistant\""); - let deserialized: Role = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, Role::Assistant); - } - - #[test] - fn test_channel_serde_roundtrip() { - let channel = Channel::Final; - let json = serde_json::to_string(&channel).unwrap(); - assert_eq!(json, "\"final\""); - let deserialized: Channel = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, Channel::Final); - } - - #[test] - fn test_content_type_serde_roundtrip() { - let ct = ContentType::ToolCall; - let json = serde_json::to_string(&ct).unwrap(); - assert_eq!(json, "\"tool_call\""); - let deserialized: ContentType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, ContentType::ToolCall); - } - - #[test] - fn test_content_type_resource_ref() { - let ct = ContentType::ResourceRef; - let json = serde_json::to_string(&ct).unwrap(); - assert_eq!(json, "\"resource_ref\""); - } - - #[test] - fn test_content_type_prompt_variants() { - let req = ContentType::PromptRequest; - let res = ContentType::PromptResult; - assert_eq!(serde_json::to_string(&req).unwrap(), "\"prompt_request\""); - assert_eq!(serde_json::to_string(&res).unwrap(), "\"prompt_result\""); - } - - #[test] - fn test_resource_type_serde_roundtrip() { - let rt = ResourceType::Database; - let json = serde_json::to_string(&rt).unwrap(); - assert_eq!(json, "\"database\""); - let deserialized: ResourceType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, ResourceType::Database); - } - - #[test] - fn test_all_roles_deserialize() { - for (s, expected) in &[ - ("\"system\"", Role::System), - ("\"developer\"", Role::Developer), - ("\"user\"", Role::User), - ("\"assistant\"", Role::Assistant), - ("\"tool\"", Role::Tool), - ] { - let role: Role = serde_json::from_str(s).unwrap(); - assert_eq!(role, *expected); - } - } -} diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs deleted file mode 100644 index a8818b4d..00000000 --- a/crates/cpex-core/src/cmf/message.rs +++ /dev/null @@ -1,468 +0,0 @@ -// Location: ./crates/cpex-core/src/cmf/message.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CMF Message — canonical message representation. -// -// A Message is the storage and wire format for a single turn in a -// conversation. It preserves structure exactly as the LLM or -// framework sent it. -// -// Extensions are NOT part of the Message. They are passed separately -// to handlers via the framework's Extensions type. This allows -// extensions to be shared across payload types and avoids copying -// the message when extensions change. -// -// Mirrors the Python Message in cpex/framework/cmf/message.py. - -use serde::{Deserialize, Serialize}; - -use super::content::*; -use super::enums::{Channel, Role}; -use crate::hooks::trait_def::PluginResult; - -/// Canonical CMF message representing a single turn in a conversation. -/// -/// All content is carried as typed ContentPart variants. Extensions -/// (identity, security, HTTP, agent context) are passed separately -/// to handlers — not inside the message. -/// -/// Mirrors the Python `Message` in `cpex/framework/cmf/message.py`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - /// Message schema version. - #[serde(default = "default_schema_version")] - pub schema_version: String, - - /// Who is speaking. - pub role: Role, - - /// List of typed content parts (multimodal). - #[serde(default)] - pub content: Vec, - - /// Optional output classification. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, -} - -fn default_schema_version() -> String { - super::constants::SCHEMA_VERSION.to_string() -} - -impl Message { - /// Create a simple text message. - pub fn text(role: Role, text: impl Into) -> Self { - Self { - schema_version: super::constants::SCHEMA_VERSION.to_string(), - role, - content: vec![ContentPart::Text { text: text.into() }], - channel: None, - } - } - - /// Create a message from an arbitrary list of typed content - /// parts. The schema version is set from `SCHEMA_VERSION` — - /// callers never hardcode it. Use this when the content isn't a - /// single text blob (tool calls, prompt requests, resource refs, - /// multimodal mixes). - pub fn with_content(role: Role, content: Vec) -> Self { - Self { - schema_version: super::constants::SCHEMA_VERSION.to_string(), - role, - content, - channel: None, - } - } - - /// Extract all text content from the message. - /// - /// Concatenates text from all `Text` content parts. - /// - /// Reads `Text` parts and nothing else, so it is not a stand-in for - /// message equality or change detection: two messages differing only - /// in a tool call, tool result, thinking block, or attachment return - /// the same string. Callers asking "did this change?" need a signal - /// from whatever performed the change. - pub fn get_text_content(&self) -> String { - let mut texts = Vec::new(); - for part in &self.content { - if let ContentPart::Text { text } = part { - texts.push(text.as_str()); - } - } - texts.join("") - } - - /// Extract thinking/reasoning content if present. - pub fn get_thinking_content(&self) -> Option { - let mut texts = Vec::new(); - for part in &self.content { - if let ContentPart::Thinking { text } = part { - texts.push(text.as_str()); - } - } - if texts.is_empty() { - None - } else { - Some(texts.join("")) - } - } - - /// Get all tool calls in this message. - pub fn get_tool_calls(&self) -> Vec<&ToolCall> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::ToolCall { content } => Some(content), - _ => None, - }) - .collect() - } - - /// Get all tool results in this message. - pub fn get_tool_results(&self) -> Vec<&ToolResult> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::ToolResult { content } => Some(content), - _ => None, - }) - .collect() - } - - /// Whether this message contains any tool calls. - pub fn is_tool_call(&self) -> bool { - self.content - .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })) - } - - /// Whether this message contains any tool results. - pub fn is_tool_result(&self) -> bool { - self.content - .iter() - .any(|p| matches!(p, ContentPart::ToolResult { .. })) - } - - /// Get all embedded resources in this message. - pub fn get_resources(&self) -> Vec<&Resource> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::Resource { content } => Some(content), - _ => None, - }) - .collect() - } - - /// Get all resource references in this message. - pub fn get_resource_refs(&self) -> Vec<&ResourceReference> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::ResourceRef { content } => Some(content), - _ => None, - }) - .collect() - } - - /// Get all resource URIs (both embedded and references). - pub fn get_all_resource_uris(&self) -> Vec<&str> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::Resource { content } => Some(content.uri.as_str()), - ContentPart::ResourceRef { content } => Some(content.uri.as_str()), - _ => None, - }) - .collect() - } - - /// Whether this message contains any resources or resource references. - pub fn has_resources(&self) -> bool { - self.content.iter().any(|p| { - matches!( - p, - ContentPart::Resource { .. } | ContentPart::ResourceRef { .. } - ) - }) - } - - /// Get all prompt requests in this message. - pub fn get_prompt_requests(&self) -> Vec<&PromptRequest> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::PromptRequest { content } => Some(content), - _ => None, - }) - .collect() - } - - /// Get all prompt results in this message. - pub fn get_prompt_results(&self) -> Vec<&PromptResult> { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::PromptResult { content } => Some(content), - _ => None, - }) - .collect() - } -} - -/// CMF Message wrapped as a PluginPayload for hook dispatch. -/// -/// This is the payload type for all `cmf.*` hooks. Plugins that -/// handle CMF hooks implement `HookHandler` and receive -/// `&MessagePayload` in their handler. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessagePayload { - /// The CMF message. - pub message: Message, -} - -crate::impl_plugin_payload!(MessagePayload); - -crate::define_hook! { - /// CMF message evaluation hook. - /// - /// Plugins implement `HookHandler` and register under - /// one or more `cmf.*` hook names (e.g., `cmf.tool_pre_invoke`, - /// `cmf.llm_input`). The same handler covers all CMF hook points. - CmfHook, "cmf" => { - payload: MessagePayload, - result: PluginResult, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::payload::PluginPayload; - use crate::hooks::trait_def::HookTypeDef; - - #[test] - fn test_message_text_helper() { - let msg = Message::text(Role::User, "What is the weather?"); - assert_eq!(msg.get_text_content(), "What is the weather?"); - assert_eq!(msg.role, Role::User); - assert_eq!(msg.schema_version, "2.0"); - } - - #[test] - fn test_message_multi_part_text() { - let msg = Message { - schema_version: "2.0".into(), - role: Role::Assistant, - content: vec![ - ContentPart::Text { - text: "Hello ".into(), - }, - ContentPart::Text { - text: "world!".into(), - }, - ], - channel: None, - }; - assert_eq!(msg.get_text_content(), "Hello world!"); - } - - #[test] - fn test_message_thinking_content() { - let msg = Message { - schema_version: "2.0".into(), - role: Role::Assistant, - content: vec![ - ContentPart::Thinking { - text: "Let me think...".into(), - }, - ContentPart::Text { - text: "Here's my answer.".into(), - }, - ], - channel: Some(Channel::Final), - }; - assert_eq!( - msg.get_thinking_content(), - Some("Let me think...".to_string()) - ); - assert_eq!(msg.get_text_content(), "Here's my answer."); - } - - #[test] - fn test_message_tool_calls() { - let msg = Message { - schema_version: "2.0".into(), - role: Role::Assistant, - content: vec![ - ContentPart::Text { - text: "Let me check.".into(), - }, - ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".into(), - name: "get_weather".into(), - arguments: [("city".to_string(), serde_json::json!("London"))].into(), - namespace: None, - }, - }, - ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_002".into(), - name: "get_time".into(), - arguments: [("timezone".to_string(), serde_json::json!("UTC"))].into(), - namespace: None, - }, - }, - ], - channel: None, - }; - assert!(msg.is_tool_call()); - assert!(!msg.is_tool_result()); - let calls = msg.get_tool_calls(); - assert_eq!(calls.len(), 2); - assert_eq!(calls[0].name, "get_weather"); - assert_eq!(calls[1].name, "get_time"); - } - - #[test] - fn test_message_tool_results() { - let msg = Message { - schema_version: "2.0".into(), - role: Role::Tool, - content: vec![ContentPart::ToolResult { - content: ToolResult { - tool_call_id: "tc_001".into(), - tool_name: "get_weather".into(), - content: serde_json::json!({"temp": 20}), - is_error: false, - }, - }], - channel: None, - }; - assert!(msg.is_tool_result()); - assert!(!msg.is_tool_call()); - let results = msg.get_tool_results(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].tool_name, "get_weather"); - } - - #[test] - fn test_message_resources() { - let msg = Message { - schema_version: "2.0".into(), - role: Role::Assistant, - content: vec![ - ContentPart::Resource { - content: Resource { - resource_request_id: "rr_001".into(), - uri: "file:///data.txt".into(), - name: Some("Data File".into()), - description: None, - resource_type: super::super::enums::ResourceType::File, - content: Some("file contents".into()), - blob: None, - mime_type: None, - size_bytes: None, - annotations: std::collections::HashMap::new(), - version: None, - }, - }, - ContentPart::ResourceRef { - content: ResourceReference { - resource_request_id: "rr_002".into(), - uri: "db://users/42".into(), - name: None, - resource_type: super::super::enums::ResourceType::Database, - range_start: None, - range_end: None, - selector: None, - }, - }, - ], - channel: None, - }; - assert!(msg.has_resources()); - assert_eq!(msg.get_resources().len(), 1); - assert_eq!(msg.get_resource_refs().len(), 1); - let uris = msg.get_all_resource_uris(); - assert_eq!(uris.len(), 2); - assert!(uris.contains(&"file:///data.txt")); - assert!(uris.contains(&"db://users/42")); - } - - #[test] - fn test_message_no_resources() { - let msg = Message::text(Role::User, "Hello"); - assert!(!msg.has_resources()); - assert!(msg.get_resources().is_empty()); - } - - #[test] - fn test_message_serde_roundtrip() { - let msg = Message { - schema_version: "2.0".into(), - role: Role::Assistant, - content: vec![ - ContentPart::Thinking { - text: "Analyzing...".into(), - }, - ContentPart::Text { - text: "Here's the answer.".into(), - }, - ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".into(), - name: "search".into(), - arguments: [("q".to_string(), serde_json::json!("rust"))].into(), - namespace: None, - }, - }, - ], - channel: Some(Channel::Final), - }; - - let json = serde_json::to_string(&msg).unwrap(); - let deserialized: Message = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.role, Role::Assistant); - assert_eq!(deserialized.schema_version, "2.0"); - assert_eq!(deserialized.channel, Some(Channel::Final)); - assert_eq!(deserialized.content.len(), 3); - assert_eq!(deserialized.get_text_content(), "Here's the answer."); - assert_eq!(deserialized.get_tool_calls().len(), 1); - } - - #[test] - fn test_message_payload_as_plugin_payload() { - let payload = MessagePayload { - message: Message::text(Role::User, "Hello"), - }; - - // Test clone_boxed - let boxed: Box = Box::new(payload.clone()); - let cloned = boxed.clone_boxed(); - - // Test as_any downcast - let downcasted = cloned - .as_any() - .downcast_ref::() - .expect("should downcast to MessagePayload"); - assert_eq!(downcasted.message.get_text_content(), "Hello"); - } - - #[test] - fn test_cmf_hook_type_def() { - assert_eq!(CmfHook::NAME, "cmf"); - } - - #[test] - fn test_message_default_schema_version() { - let json = r#"{"role":"user","content":[]}"#; - let msg: Message = serde_json::from_str(json).unwrap(); - assert_eq!(msg.schema_version, "2.0"); - } -} diff --git a/crates/cpex-core/src/cmf/mod.rs b/crates/cpex-core/src/cmf/mod.rs deleted file mode 100644 index 11ae76a4..00000000 --- a/crates/cpex-core/src/cmf/mod.rs +++ /dev/null @@ -1,100 +0,0 @@ -// Location: ./crates/cpex-core/src/cmf/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// ContextForge Message Format (CMF). -// -// Canonical message representation for interactions between users, -// agents, tools, and language models. All models mirror the Python -// CMF in cpex/framework/cmf/message.py. -// -// Extensions are NOT part of the Message — they are passed separately -// to handlers via the framework's Extensions type in hooks/payload.rs. -// This allows extensions to be shared across payload types and avoids -// copying the message when extensions change. -// -// # Hook Registration Patterns -// -// CMF supports two registration patterns for plugins: -// -// ## Pattern 1: One handler, multiple hook names (recommended) -// -// Use `CmfHook` as the hook type and register under multiple names. -// The plugin writes one handler that covers all CMF hooks. The host -// invokes via `invoke_by_name("cmf.tool_pre_invoke", ...)`. -// -// ```rust,ignore -// // Plugin implements one handler: -// impl HookHandler for MyPlugin { -// fn handle(&self, payload: &MessagePayload, ext: &Extensions, ctx: &mut PluginContext) -// -> PluginResult { ... } -// } -// -// // Factory registers under multiple names: -// PluginInstance { -// plugin: plugin.clone(), -// handlers: vec![ -// ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), -// ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), -// ], -// } -// -// // Host invokes via invoke_named — compile-time payload type safety -// // plus runtime hook name routing: -// mgr.invoke_named::( -// "cmf.tool_pre_invoke", payload, ext, None, -// ).await; -// ``` -// -// `invoke_named::(hook_name, ...)` gives you both: -// - **Compile-time**: payload must be `MessagePayload` (from `CmfHook::Payload`) -// - **Runtime**: dispatches to plugins registered under the specific hook name -// -// This is the recommended approach for CMF hooks. Alternatively, use -// `invoke_by_name(hook_name, boxed_payload, ...)` for fully dynamic -// dispatch (no compile-time payload check). -// -// ## Pattern 2: Individual hook types (optional) -// -// For hosts that want per-hook marker types, define separate hook -// types. Each maps to one hook name. The plugin must implement a -// handler per type (more boilerplate). -// -// ```rust,ignore -// define_hook! { -// CmfToolPreInvoke, "cmf.tool_pre_invoke" => { -// payload: MessagePayload, -// result: PluginResult, -// } -// } -// -// // Plugin implements per-hook handlers: -// impl HookHandler for MyPlugin { ... } -// impl HookHandler for MyPlugin { ... } -// -// // Host uses typed invoke: -// mgr.invoke::(payload, ext, None).await; -// ``` -// -// Both patterns use the same executor, registry, and capabilities. -// Pattern 1 with `invoke_named` is recommended — one handler impl, -// compile-time payload safety, and explicit hook name routing. -// -// Available CMF hook names (defined in hooks/types.rs): -// cmf.tool_pre_invoke, cmf.tool_post_invoke, -// cmf.llm_input, cmf.llm_output, -// cmf.prompt_pre_fetch, cmf.prompt_post_fetch, -// cmf.resource_pre_fetch, cmf.resource_post_fetch - -pub mod constants; -pub mod content; -pub mod enums; -pub mod message; -pub mod view; - -// Re-export key types at the cmf module level -pub use content::*; -pub use enums::*; -pub use message::{CmfHook, Message, MessagePayload}; -pub use view::{MessageView, ViewAction, ViewKind}; diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs deleted file mode 100644 index da320a22..00000000 --- a/crates/cpex-core/src/cmf/view.rs +++ /dev/null @@ -1,852 +0,0 @@ -// Location: ./crates/cpex-core/src/cmf/view.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// MessageView — read-only projection for policy evaluation. -// -// Decomposes a Message into individually addressable views with a -// uniform interface regardless of content type. Zero-copy design — -// properties are computed on-demand by borrowing the underlying -// content part and extensions directly. -// -// Mirrors the Python MessageView in cpex/framework/cmf/view.py. - -use serde::{Deserialize, Serialize}; - -use super::content::*; -use super::enums::{ContentType, Role}; -use super::message::Message; -use crate::hooks::payload::Extensions; - -/// Type of content a view represents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ViewKind { - Text, - Thinking, - ToolCall, - ToolResult, - Resource, - ResourceRef, - PromptRequest, - PromptResult, - Image, - Video, - Audio, - Document, -} - -/// The action this content represents in the data flow. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ViewAction { - Read, - Write, - Execute, - Invoke, - Send, - Receive, - Generate, -} - -impl ViewKind { - /// Map ContentType to ViewKind. - pub fn from_content_type(ct: ContentType) -> Self { - match ct { - ContentType::Text => ViewKind::Text, - ContentType::Thinking => ViewKind::Thinking, - ContentType::ToolCall => ViewKind::ToolCall, - ContentType::ToolResult => ViewKind::ToolResult, - ContentType::Resource => ViewKind::Resource, - ContentType::ResourceRef => ViewKind::ResourceRef, - ContentType::PromptRequest => ViewKind::PromptRequest, - ContentType::PromptResult => ViewKind::PromptResult, - ContentType::Image => ViewKind::Image, - ContentType::Video => ViewKind::Video, - ContentType::Audio => ViewKind::Audio, - ContentType::Document => ViewKind::Document, - } - } - - /// The default action for this kind of content. - pub fn default_action(&self, role: Role) -> ViewAction { - match self { - ViewKind::ToolCall => ViewAction::Execute, - ViewKind::ToolResult => ViewAction::Receive, - ViewKind::Resource | ViewKind::ResourceRef => ViewAction::Read, - ViewKind::PromptRequest => ViewAction::Invoke, - ViewKind::PromptResult => ViewAction::Receive, - // Direction-dependent kinds - ViewKind::Text - | ViewKind::Thinking - | ViewKind::Image - | ViewKind::Video - | ViewKind::Audio - | ViewKind::Document => match role { - Role::User => ViewAction::Send, - Role::Assistant => ViewAction::Generate, - Role::Tool => ViewAction::Receive, - Role::System | Role::Developer => ViewAction::Write, - }, - } - } - - /// Whether this is a tool-related kind. - pub fn is_tool(&self) -> bool { - matches!(self, ViewKind::ToolCall | ViewKind::ToolResult) - } - - /// Whether this is a resource-related kind. - pub fn is_resource(&self) -> bool { - matches!(self, ViewKind::Resource | ViewKind::ResourceRef) - } - - /// Whether this is a prompt-related kind. - pub fn is_prompt(&self) -> bool { - matches!(self, ViewKind::PromptRequest | ViewKind::PromptResult) - } - - /// Whether this is a media kind (image, video, audio, document). - pub fn is_media(&self) -> bool { - matches!( - self, - ViewKind::Image | ViewKind::Video | ViewKind::Audio | ViewKind::Document - ) - } - - /// Whether this is a text kind (text or thinking). - pub fn is_text(&self) -> bool { - matches!(self, ViewKind::Text | ViewKind::Thinking) - } -} - -/// Read-only, zero-copy view over a single content part. -/// -/// Provides a uniform interface for policy evaluation regardless -/// of content type. Properties are computed on-demand by borrowing -/// the underlying content part and extensions. -/// -/// Produced by `Message::iter_views()` or the standalone `iter_views()`. -pub struct MessageView<'a> { - /// The underlying content part. - part: &'a ContentPart, - /// The kind of content. - kind: ViewKind, - /// The parent message role. - role: Role, - /// Optional hook location (e.g., "tool_pre_invoke"). - hook: Option<&'a str>, - /// Optional extensions (for security/http context). - extensions: Option<&'a Extensions>, -} - -impl<'a> MessageView<'a> { - /// Create a new view over a content part. - pub fn new( - part: &'a ContentPart, - role: Role, - hook: Option<&'a str>, - extensions: Option<&'a Extensions>, - ) -> Self { - let kind = match part { - ContentPart::Text { .. } => ViewKind::Text, - ContentPart::Thinking { .. } => ViewKind::Thinking, - ContentPart::ToolCall { .. } => ViewKind::ToolCall, - ContentPart::ToolResult { .. } => ViewKind::ToolResult, - ContentPart::Resource { .. } => ViewKind::Resource, - ContentPart::ResourceRef { .. } => ViewKind::ResourceRef, - ContentPart::PromptRequest { .. } => ViewKind::PromptRequest, - ContentPart::PromptResult { .. } => ViewKind::PromptResult, - ContentPart::Image { .. } => ViewKind::Image, - ContentPart::Video { .. } => ViewKind::Video, - ContentPart::Audio { .. } => ViewKind::Audio, - ContentPart::Document { .. } => ViewKind::Document, - }; - - Self { - part, - kind, - role, - hook, - extensions, - } - } - - // -- Core properties -- - - /// The kind of content this view represents. - pub fn kind(&self) -> ViewKind { - self.kind - } - - /// The role of the parent message. - pub fn role(&self) -> Role { - self.role - } - - /// The underlying content part. - pub fn raw(&self) -> &'a ContentPart { - self.part - } - - /// The hook location, if set. - pub fn hook(&self) -> Option<&str> { - self.hook - } - - /// The action this content represents. - pub fn action(&self) -> ViewAction { - self.kind.default_action(self.role) - } - - // -- Phase helpers -- - - /// Whether this is a pre-execution hook (tool_pre_invoke, prompt_pre_fetch, etc.). - pub fn is_pre(&self) -> bool { - self.hook.is_some_and(|h| h.contains("pre")) - } - - /// Whether this is a post-execution hook. - pub fn is_post(&self) -> bool { - self.hook.is_some_and(|h| h.contains("post")) - } - - // -- Universal properties -- - - /// Text content (for text, thinking, tool result content). - pub fn content(&self) -> Option<&str> { - match self.part { - ContentPart::Text { text } | ContentPart::Thinking { text } => Some(text), - ContentPart::ToolResult { content: tr } => { - tr.content.as_str().map(Some).unwrap_or(None) - }, - ContentPart::Resource { content: r } => r.content.as_deref(), - ContentPart::PromptResult { content: pr } => pr.content.as_deref(), - _ => None, - } - } - - /// Entity name (tool name, resource URI, prompt name). - pub fn name(&self) -> Option<&str> { - match self.part { - ContentPart::ToolCall { content: tc } => Some(&tc.name), - ContentPart::ToolResult { content: tr } => Some(&tr.tool_name), - ContentPart::Resource { content: r } => r.name.as_deref().or(Some(&r.uri)), - ContentPart::ResourceRef { content: rr } => rr.name.as_deref().or(Some(&rr.uri)), - ContentPart::PromptRequest { content: pr } => Some(&pr.name), - ContentPart::PromptResult { content: pr } => Some(&pr.prompt_name), - _ => None, - } - } - - /// URI for the entity. - pub fn uri(&self) -> Option { - match self.part { - ContentPart::ToolCall { content: tc } => Some(format!("tool://_/{}", tc.name)), - ContentPart::Resource { content: r } => Some(r.uri.clone()), - ContentPart::ResourceRef { content: rr } => Some(rr.uri.clone()), - ContentPart::PromptRequest { content: pr } => Some(format!("prompt://_/{}", pr.name)), - _ => None, - } - } - - /// Arguments (for tool calls and prompt requests). - pub fn args(&self) -> Option<&std::collections::HashMap> { - match self.part { - ContentPart::ToolCall { content: tc } => Some(&tc.arguments), - ContentPart::PromptRequest { content: pr } => Some(&pr.arguments), - _ => None, - } - } - - /// Get a specific argument by name. - pub fn get_arg(&self, name: &str) -> Option<&serde_json::Value> { - self.args().and_then(|a| a.get(name)) - } - - /// Whether this content has arguments. - pub fn has_arg(&self, name: &str) -> bool { - self.get_arg(name).is_some() - } - - /// MIME type (for resources, media). - pub fn mime_type(&self) -> Option<&str> { - match self.part { - ContentPart::Resource { content: r } => r.mime_type.as_deref(), - ContentPart::Image { content: img } => img.media_type.as_deref(), - ContentPart::Video { content: vid } => vid.media_type.as_deref(), - ContentPart::Audio { content: aud } => aud.media_type.as_deref(), - ContentPart::Document { content: doc } => doc.media_type.as_deref(), - _ => None, - } - } - - /// Whether the result is an error (tool results, prompt results). - pub fn is_error(&self) -> bool { - match self.part { - ContentPart::ToolResult { content: tr } => tr.is_error, - ContentPart::PromptResult { content: pr } => pr.is_error, - _ => false, - } - } - - // -- Type helpers -- - - pub fn is_tool(&self) -> bool { - self.kind.is_tool() - } - pub fn is_resource(&self) -> bool { - self.kind.is_resource() - } - pub fn is_prompt(&self) -> bool { - self.kind.is_prompt() - } - pub fn is_media(&self) -> bool { - self.kind.is_media() - } - pub fn is_text(&self) -> bool { - self.kind.is_text() - } - - // -- Extension accessors -- - - /// Get the extensions, if provided. - pub fn extensions(&self) -> Option<&'a Extensions> { - self.extensions - } - - /// Check if a security label exists. - pub fn has_label(&self, label: &str) -> bool { - self.extensions - .and_then(|e| e.security.as_ref()) - .map(|s| s.has_label(label)) - .unwrap_or(false) - } - - /// Get an HTTP header value. - pub fn get_header(&self, name: &str) -> Option<&str> { - self.extensions - .and_then(|e| e.http.as_ref()) - .and_then(|h| h.get_header(name)) - } - - // -- Serialization -- - - /// Sensitive headers stripped during serialization. - const SENSITIVE_HEADERS: &'static [&'static str] = &["authorization", "cookie", "x-api-key"]; - - /// Serialize the view to a JSON-compatible map. - /// - /// Includes the view's properties, arguments, and optionally - /// text content and extension context. Sensitive headers - /// (Authorization, Cookie, X-API-Key) are stripped. - pub fn to_dict(&self, include_content: bool, include_context: bool) -> serde_json::Value { - use super::constants::*; - - let mut result = serde_json::Map::new(); - - // Core fields - result.insert(FIELD_KIND.into(), serde_json::json!(self.kind)); - result.insert(FIELD_ROLE.into(), serde_json::json!(self.role)); - result.insert(FIELD_IS_PRE.into(), serde_json::json!(self.is_pre())); - result.insert(FIELD_IS_POST.into(), serde_json::json!(self.is_post())); - result.insert(FIELD_ACTION.into(), serde_json::json!(self.action())); - - if let Some(hook) = self.hook { - result.insert(FIELD_HOOK.into(), serde_json::json!(hook)); - } - - if let Some(uri) = self.uri() { - result.insert(FIELD_URI.into(), serde_json::json!(uri)); - } - - if let Some(name) = self.name() { - result.insert(FIELD_NAME.into(), serde_json::json!(name)); - } - - // Content - if include_content { - if let Some(text) = self.content() { - result.insert(FIELD_SIZE_BYTES.into(), serde_json::json!(text.len())); - result.insert(FIELD_CONTENT.into(), serde_json::json!(text)); - } - } - - if let Some(mime) = self.mime_type() { - result.insert(FIELD_MIME_TYPE.into(), serde_json::json!(mime)); - } - - // Arguments - if let Some(args) = self.args() { - result.insert(FIELD_ARGUMENTS.into(), serde_json::json!(args)); - } - - // Extensions context - if include_context { - if let Some(ext) = self.extensions { - let mut ext_map = serde_json::Map::new(); - - // Subject - if let Some(ref sec) = ext.security { - if let Some(ref subject) = sec.subject { - let mut sub_map = serde_json::Map::new(); - if let Some(ref id) = subject.id { - sub_map.insert(FIELD_ID.into(), serde_json::json!(id)); - } - if let Some(ref st) = subject.subject_type { - sub_map.insert(FIELD_TYPE.into(), serde_json::json!(st)); - } - if !subject.roles.is_empty() { - let mut roles: Vec<&String> = subject.roles.iter().collect(); - roles.sort(); - sub_map.insert(FIELD_ROLES.into(), serde_json::json!(roles)); - } - if !subject.permissions.is_empty() { - let mut perms: Vec<&String> = subject.permissions.iter().collect(); - perms.sort(); - sub_map.insert(FIELD_PERMISSIONS.into(), serde_json::json!(perms)); - } - if !subject.teams.is_empty() { - let mut teams: Vec<&String> = subject.teams.iter().collect(); - teams.sort(); - sub_map.insert(FIELD_TEAMS.into(), serde_json::json!(teams)); - } - if !sub_map.is_empty() { - ext_map - .insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); - } - } - - // Labels - if !sec.labels.is_empty() { - let mut labels: Vec<&String> = sec.labels.iter().collect(); - labels.sort(); - ext_map.insert(FIELD_LABELS.into(), serde_json::json!(labels)); - } - } - - // Environment - if let Some(ref req) = ext.request { - if let Some(ref env) = req.environment { - ext_map.insert(FIELD_ENVIRONMENT.into(), serde_json::json!(env)); - } - } - - // Request headers (strip sensitive) - if let Some(ref http) = ext.http { - let safe: std::collections::HashMap<&String, &String> = http - .request_headers - .iter() - .filter(|(k, _)| { - !Self::SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) - }) - .collect(); - if !safe.is_empty() { - ext_map.insert(FIELD_HEADERS.into(), serde_json::json!(safe)); - } - } - - // Agent context - if let Some(ref agent) = ext.agent { - let mut agent_map = serde_json::Map::new(); - if let Some(ref input) = agent.input { - agent_map.insert(FIELD_INPUT.into(), serde_json::json!(input)); - } - if let Some(ref sid) = agent.session_id { - agent_map.insert(FIELD_SESSION_ID.into(), serde_json::json!(sid)); - } - if let Some(ref cid) = agent.conversation_id { - agent_map.insert(FIELD_CONVERSATION_ID.into(), serde_json::json!(cid)); - } - if let Some(turn) = agent.turn { - agent_map.insert(FIELD_TURN.into(), serde_json::json!(turn)); - } - if let Some(ref aid) = agent.agent_id { - agent_map.insert(FIELD_AGENT_ID.into(), serde_json::json!(aid)); - } - if let Some(ref paid) = agent.parent_agent_id { - agent_map.insert(FIELD_PARENT_AGENT_ID.into(), serde_json::json!(paid)); - } - if !agent_map.is_empty() { - ext_map.insert(FIELD_AGENT.into(), serde_json::Value::Object(agent_map)); - } - } - - // Meta - if let Some(ref meta) = ext.meta { - let mut meta_map = serde_json::Map::new(); - if let Some(ref et) = meta.entity_type { - meta_map.insert(FIELD_ENTITY_TYPE.into(), serde_json::json!(et)); - } - if let Some(ref en) = meta.entity_name { - meta_map.insert(FIELD_ENTITY_NAME.into(), serde_json::json!(en)); - } - if !meta.tags.is_empty() { - let mut tags: Vec<&String> = meta.tags.iter().collect(); - tags.sort(); - meta_map.insert(FIELD_TAGS.into(), serde_json::json!(tags)); - } - if !meta_map.is_empty() { - ext_map.insert(FIELD_META.into(), serde_json::Value::Object(meta_map)); - } - } - - if !ext_map.is_empty() { - result.insert(FIELD_EXTENSIONS.into(), serde_json::Value::Object(ext_map)); - } - } - } - - serde_json::Value::Object(result) - } - - /// Serialize to OPA-compatible input format. - /// - /// Wraps the view in the standard OPA input envelope: - /// `{"input": {...view data...}}`. - pub fn to_opa_input(&self, include_content: bool) -> serde_json::Value { - use super::constants::FIELD_OPA_INPUT; - serde_json::json!({ - FIELD_OPA_INPUT: self.to_dict(include_content, true) - }) - } -} - -impl<'a> std::fmt::Debug for MessageView<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MessageView") - .field("kind", &self.kind) - .field("role", &self.role) - .field("name", &self.name()) - .field("hook", &self.hook) - .finish() - } -} - -/// Decompose a Message into individually addressable MessageViews. -/// -/// Yields one view per content part. Each view provides a uniform -/// interface for policy evaluation regardless of content type. -pub fn iter_views<'a>( - message: &'a Message, - hook: Option<&'a str>, - extensions: Option<&'a Extensions>, -) -> impl Iterator> { - message - .content - .iter() - .map(move |part| MessageView::new(part, message.role, hook, extensions)) -} - -// Also add iter_views to Message -impl Message { - /// Decompose this message into individually addressable MessageViews. - /// - /// Yields one view per content part. Each view provides a uniform - /// interface for policy evaluation regardless of content type. - pub fn iter_views<'a>( - &'a self, - hook: Option<&'a str>, - extensions: Option<&'a Extensions>, - ) -> impl Iterator> { - iter_views(self, hook, extensions) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cmf::enums::Role; - use crate::hooks::payload::MetaExtension; - - fn make_test_message() -> Message { - Message { - schema_version: "2.0".into(), - role: Role::Assistant, - content: vec![ - ContentPart::Thinking { - text: "Let me think...".into(), - }, - ContentPart::Text { - text: "Here's the answer.".into(), - }, - ContentPart::ToolCall { - content: ToolCall { - tool_call_id: "tc_001".into(), - name: "get_weather".into(), - arguments: [("city".to_string(), serde_json::json!("London"))].into(), - namespace: None, - }, - }, - ContentPart::Resource { - content: Resource { - resource_request_id: "rr_001".into(), - uri: "file:///data.csv".into(), - name: Some("Data File".into()), - resource_type: crate::cmf::enums::ResourceType::File, - content: Some("col1,col2".into()), - mime_type: Some("text/csv".into()), - ..Default::default() - }, - }, - ], - channel: None, - } - } - - #[test] - fn test_iter_views_count() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views.len(), 4); - } - - #[test] - fn test_view_kinds() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views[0].kind(), ViewKind::Thinking); - assert_eq!(views[1].kind(), ViewKind::Text); - assert_eq!(views[2].kind(), ViewKind::ToolCall); - assert_eq!(views[3].kind(), ViewKind::Resource); - } - - #[test] - fn test_view_content() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views[0].content(), Some("Let me think...")); - assert_eq!(views[1].content(), Some("Here's the answer.")); - assert!(views[2].content().is_none()); // tool call has no text content - assert_eq!(views[3].content(), Some("col1,col2")); // resource has text content - } - - #[test] - fn test_view_name() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert!(views[0].name().is_none()); // thinking has no name - assert!(views[1].name().is_none()); // text has no name - assert_eq!(views[2].name(), Some("get_weather")); - assert_eq!(views[3].name(), Some("Data File")); - } - - #[test] - fn test_view_uri() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views[2].uri(), Some("tool://_/get_weather".to_string())); - assert_eq!(views[3].uri(), Some("file:///data.csv".to_string())); - } - - #[test] - fn test_view_args() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - let tool_view = &views[2]; - assert!(tool_view.has_arg("city")); - assert_eq!(tool_view.get_arg("city").unwrap(), "London"); - assert!(!tool_view.has_arg("nonexistent")); - } - - #[test] - fn test_view_action() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views[0].action(), ViewAction::Generate); // thinking from assistant - assert_eq!(views[1].action(), ViewAction::Generate); // text from assistant - assert_eq!(views[2].action(), ViewAction::Execute); // tool call - assert_eq!(views[3].action(), ViewAction::Read); // resource - } - - #[test] - fn test_view_action_user_role() { - let msg = Message::text(Role::User, "Hello"); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views[0].action(), ViewAction::Send); // text from user - } - - #[test] - fn test_view_hook_pre_post() { - let msg = make_test_message(); - let pre_views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect(); - assert!(pre_views[0].is_pre()); - assert!(!pre_views[0].is_post()); - - let post_views: Vec<_> = msg.iter_views(Some("tool_post_invoke"), None).collect(); - assert!(post_views[0].is_post()); - assert!(!post_views[0].is_pre()); - } - - #[test] - fn test_view_type_helpers() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert!(views[0].is_text()); // thinking - assert!(views[1].is_text()); // text - assert!(views[2].is_tool()); // tool call - assert!(views[3].is_resource()); // resource - } - - #[test] - fn test_view_mime_type() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, None).collect(); - assert_eq!(views[3].mime_type(), Some("text/csv")); - } - - #[test] - fn test_view_with_extensions() { - use crate::extensions::{HttpExtension, SecurityExtension}; - use std::sync::Arc; - - let mut security = SecurityExtension::default(); - security.add_label("PII"); - - let mut http = HttpExtension::default(); - http.set_header("Authorization", "Bearer tok"); - - let ext = Extensions { - security: Some(Arc::new(security)), - http: Some(Arc::new(http)), - ..Default::default() - }; - - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect(); - - assert!(views[0].has_label("PII")); - assert!(!views[0].has_label("HIPAA")); - assert_eq!(views[0].get_header("Authorization"), Some("Bearer tok")); - } - - #[test] - fn test_to_dict_basic() { - let msg = Message::text(Role::User, "Hello world"); - let views: Vec<_> = msg.iter_views(Some("llm_input"), None).collect(); - let dict = views[0].to_dict(true, false); - - assert_eq!(dict["kind"], "text"); - assert_eq!(dict["role"], "user"); - assert_eq!(dict["action"], "send"); - assert_eq!(dict["hook"], "llm_input"); - assert_eq!(dict["content"], "Hello world"); - assert_eq!(dict["size_bytes"], 11); - assert_eq!(dict["is_pre"], false); - assert_eq!(dict["is_post"], false); - } - - #[test] - fn test_to_dict_tool_call() { - let msg = make_test_message(); - let views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect(); - let dict = views[2].to_dict(true, false); // tool call - - assert_eq!(dict["kind"], "tool_call"); - assert_eq!(dict["name"], "get_weather"); - assert_eq!(dict["uri"], "tool://_/get_weather"); - assert_eq!(dict["action"], "execute"); - assert_eq!(dict["is_pre"], true); - assert!(dict["arguments"].is_object()); - assert_eq!(dict["arguments"]["city"], "London"); - } - - #[test] - fn test_to_dict_without_content() { - let msg = Message::text(Role::User, "Secret message"); - let views: Vec<_> = msg.iter_views(None, None).collect(); - let dict = views[0].to_dict(false, false); - - assert!(dict.get("content").is_none()); - assert!(dict.get("size_bytes").is_none()); - } - - #[test] - fn test_to_dict_with_extensions() { - use crate::extensions::{ - AgentExtension, HttpExtension, RequestExtension, SecurityExtension, - }; - use std::sync::Arc; - - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.subject = Some(crate::extensions::security::SubjectExtension { - id: Some("alice".into()), - subject_type: Some(crate::extensions::security::SubjectType::User), - roles: ["admin".to_string()].into(), - ..Default::default() - }); - - let mut http = HttpExtension::default(); - http.set_header("Authorization", "Bearer secret"); - http.set_header("X-Request-ID", "req-123"); - - let ext = Extensions { - security: Some(Arc::new(security)), - http: Some(Arc::new(http)), - request: Some(Arc::new(RequestExtension { - environment: Some("production".into()), - ..Default::default() - })), - agent: Some(Arc::new(AgentExtension { - session_id: Some("sess-001".into()), - agent_id: Some("agent-x".into()), - ..Default::default() - })), - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - tags: ["pii".to_string()].into(), - ..Default::default() - })), - ..Default::default() - }; - - let msg = Message::text(Role::User, "test"); - let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect(); - let dict = views[0].to_dict(true, true); - - let extensions = &dict["extensions"]; - - // Subject visible - assert_eq!(extensions["subject"]["id"], "alice"); - assert!(extensions["subject"]["roles"] - .as_array() - .unwrap() - .contains(&serde_json::json!("admin"))); - - // Labels visible - assert!(extensions["labels"] - .as_array() - .unwrap() - .contains(&serde_json::json!("PII"))); - - // Environment visible - assert_eq!(extensions["environment"], "production"); - - // Headers visible — but Authorization stripped (sensitive) - assert!(extensions["headers"].get("Authorization").is_none()); - assert_eq!(extensions["headers"]["X-Request-ID"], "req-123"); - - // Agent context visible - assert_eq!(extensions["agent"]["session_id"], "sess-001"); - assert_eq!(extensions["agent"]["agent_id"], "agent-x"); - - // Meta visible - assert_eq!(extensions["meta"]["entity_type"], "tool"); - assert_eq!(extensions["meta"]["entity_name"], "get_compensation"); - } - - #[test] - fn test_to_opa_input() { - let msg = Message::text(Role::User, "Hello"); - let views: Vec<_> = msg.iter_views(None, None).collect(); - let opa = views[0].to_opa_input(true); - - assert!(opa.get("input").is_some()); - assert_eq!(opa["input"]["kind"], "text"); - assert_eq!(opa["input"]["role"], "user"); - assert_eq!(opa["input"]["content"], "Hello"); - } -} diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs deleted file mode 100644 index f758f430..00000000 --- a/crates/cpex-core/src/config.rs +++ /dev/null @@ -1,2486 +0,0 @@ -// Location: ./crates/cpex-core/src/config.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Unified YAML configuration parsing. -// -// Parses the config format that combines global settings, plugin -// declarations, and per-entity routes into a single YAML document. -// -// Supports two modes controlled by `plugin_settings.routing_enabled`: -// - false (default, backward compatible): plugins declare their -// own conditions for when they fire. -// - true: per-entity routing rules determine which plugins fire, -// with plugin selection via policy groups and meta.tags. -// -// The two modes are mutually exclusive. When routing is disabled, -// the routes and global sections are ignored. When routing is -// enabled, conditions on individual plugins are ignored. - -use std::collections::{HashMap, HashSet}; -use std::path::Path; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::error::PluginError; -use crate::plugin::PluginConfig; - -/// Top-level CPEX configuration. -/// -/// Parsed from a single YAML file. Plugin scoping mode is controlled -/// by `plugin_settings.routing_enabled` — if absent or false, plugins -/// use their own `conditions:` field (backward compatible). If true, -/// the `routes:` and `global:` sections take over. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CpexConfig { - /// Global configuration — policies, defaults. - /// Only used when `plugin_settings.routing_enabled` is true. - #[serde(default)] - pub global: GlobalConfig, - - /// Directories to scan for plugin modules. - #[serde(default)] - pub plugin_dirs: Vec, - - /// Plugin declarations. - #[serde(default)] - pub plugins: Vec, - - /// Named policy bundles a route can join, keyed by group name. The - /// canonical, top-level spelling — lines up with `global:` (always-on - /// defaults) and `routes:` (per-entity policy) as the third concern. - /// - /// Superset-compatible with the older `global.policies:` location, which - /// stays accepted as a deprecated alias: at parse time both are merged - /// into one bundle map (`global.policies`, the internal store the - /// resolvers read), with entries here winning on a name collision. - #[serde(default)] - pub groups: HashMap, - - /// Per-entity routing rules. - /// Only used when `plugin_settings.routing_enabled` is true. - #[serde(default)] - pub routes: Vec, - - /// Global plugin settings (timeout, error behavior, routing mode). - #[serde(default)] - pub plugin_settings: PluginSettings, -} - -impl CpexConfig { - /// Whether route-based plugin selection is enabled. - pub fn routing_enabled(&self) -> bool { - self.plugin_settings.routing_enabled - } -} - -/// Global plugin settings. -/// -/// Controls executor behavior and routing mode. All fields have -/// sensible defaults — a missing `plugin_settings:` section is valid. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginSettings { - /// Enable route-based plugin selection. - /// When false (default), plugins use their own `conditions:` field. - /// When true, the `routes:` and `global:` sections determine which - /// plugins fire per entity. - #[serde(default)] - pub routing_enabled: bool, - - /// Default timeout per plugin in seconds. - #[serde(default = "default_timeout")] - pub plugin_timeout: u64, - - /// Whether to halt on first deny in concurrent mode. - #[serde(default = "default_true")] - pub short_circuit_on_deny: bool, - - /// Whether plugins can execute in parallel within a mode band. - #[serde(default)] - pub parallel_execution_within_band: bool, - - /// Whether to halt the pipeline on any plugin error. - #[serde(default)] - pub fail_on_plugin_error: bool, - - /// Maximum number of entries in the routing cache. - /// - /// When the cache reaches this size, new resolutions are computed - /// normally but not memoized — the cache rejects further inserts - /// and emits a warning. This bounds memory growth from - /// attacker-controlled entity names without the reasoning hazards - /// of eviction (silently dropped entries, stale-vs-current - /// confusion). Operators see the warning and tune the cap or - /// investigate the entity-name growth. - #[serde(default = "default_route_cache_max_entries")] - pub route_cache_max_entries: usize, -} - -impl Default for PluginSettings { - fn default() -> Self { - Self { - routing_enabled: false, - plugin_timeout: 30, - short_circuit_on_deny: true, - parallel_execution_within_band: false, - fail_on_plugin_error: false, - route_cache_max_entries: default_route_cache_max_entries(), - } - } -} - -fn default_route_cache_max_entries() -> usize { - 10_000 -} - -fn default_timeout() -> u64 { - 30 -} - -fn default_true() -> bool { - true -} - -/// Global configuration — applies across all routes. -/// -/// Only used when routing is enabled. Contains named policy groups -/// (including the reserved `all` group) and per-entity-type defaults. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GlobalConfig { - /// Named policy groups. The reserved name `all` is applied to - /// every request unconditionally. Other groups are inherited - /// by routes via `meta.tags`. - #[serde(default)] - pub policies: HashMap, - - /// Per-entity-type default policy groups. - /// Keys are `tool`, `resource`, `prompt`, `llm`. - #[serde(default)] - pub defaults: HashMap, - - /// Global authentication dispatch list (YAML key `authentication:`). - /// Inherited by every route as the first layer of identity - /// resolution. Routes can append to it (additive, the default) or - /// replace it (with `authentication.replace_inherited: true` on the - /// route). - /// - /// Same YAML shape as the route-level `authentication:` block — see - /// `RouteEntry.identity` for the accepted forms. - #[serde( - default, - rename = "authentication", - deserialize_with = "deserialize_route_identity" - )] - pub identity: Option, -} - -/// A named policy group — plugins to activate and optional metadata. -/// -/// The `all` group is reserved and always applied. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PolicyGroup { - /// Human-readable description. - #[serde(default)] - pub description: Option, - - /// Arbitrary metadata for tooling and audit. - #[serde(default)] - pub metadata: HashMap, - - /// Plugin references to activate when this group matches. - #[serde(default, deserialize_with = "deserialize_plugin_refs")] - pub plugins: Vec, - - /// Authentication dispatch list contributed by this tag bundle - /// (YAML key `authentication:`). Inherited by routes that carry this - /// tag in `meta.tags`, stacked between the global authentication - /// (first) and the route's own authentication (last). Same YAML shape - /// as the route-level `authentication:` block. - #[serde( - default, - rename = "authentication", - deserialize_with = "deserialize_route_identity" - )] - pub identity: Option, -} - -/// A reference to a plugin in a route or policy group. -/// -/// ```yaml -/// plugins: -/// - rate_limiter # bare name -/// - pii_scanner: # name with config overrides -/// config: -/// sensitivity: high -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum PluginRouteRef { - /// Just the name — activate the plugin with no config overrides. - Name(String), - /// Name with config overrides — single-key map. - WithOverrides(HashMap), -} - -impl PluginRouteRef { - /// Extract the plugin name from this reference. - pub fn name(&self) -> &str { - match self { - Self::Name(name) => name, - Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""), - } - } - - /// Extract config overrides, if any. - pub fn overrides(&self) -> Option<&serde_json::Value> { - match self { - Self::Name(_) => None, - Self::WithOverrides(map) => map.values().next(), - } - } -} - -/// Deserialize a `plugins:` field that may take either of two YAML -/// shapes, so the `apl:` wrapper is genuinely optional everywhere. -/// -/// - A **sequence** is the structural activation list — each item is a -/// [`PluginRouteRef`] (bare name or single-key override map). It -/// deserializes into the `Vec` as usual. -/// - A **mapping** is the APL per-plugin *override* form, written -/// directly on the section when the `apl:` wrapper is omitted (e.g. -/// `plugins: { audit: { on_error: ignore } }`). It is **not** a -/// structural activation list: the override map is consumed -/// separately by the APL visitor straight from the raw YAML, so here -/// it deserializes to an empty `Vec`. This mirrors the explicit -/// `apl: { plugins: {...} }` wrapper form, where the map never -/// reaches this field at all — keeping the two forms behaviorally -/// identical (the map supplies overrides; policy steps still do the -/// activating). -/// -/// Null / absent → empty `Vec` (same as `#[serde(default)]`). -fn deserialize_plugin_refs<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de::Error; - - match serde_yaml::Value::deserialize(deserializer)? { - // Structural activation list. - serde_yaml::Value::Sequence(items) => items - .into_iter() - .map(|item| serde_yaml::from_value(item).map_err(D::Error::custom)) - .collect(), - // APL override map — owned by the APL visitor, not the - // structural parse. See doc comment above. - serde_yaml::Value::Mapping(_) => Ok(Vec::new()), - // Null / absent → no structural plugins. - serde_yaml::Value::Null => Ok(Vec::new()), - other => Err(D::Error::custom(format!( - "`plugins:` must be a sequence (activation list) or a mapping \ - (APL per-plugin overrides), got {:?}", - other - ))), - } -} - -/// A per-entity routing rule. -/// -/// Matches one entity type (tool, resource, prompt, or LLM) and -/// determines which plugins fire. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RouteEntry { - /// Match a tool by exact name, list, or glob. - #[serde(default)] - pub tool: Option, - - /// Match a resource by exact URI, list, or glob. - #[serde(default)] - pub resource: Option, - - /// Match a prompt by exact name, list, or glob. - #[serde(default)] - pub prompt: Option, - - /// Match an LLM by exact model name, list, or glob. - #[serde(default)] - pub llm: Option, - - /// Operational metadata — tags, scope, properties. - #[serde(default)] - pub meta: Option, - - /// Group bundles this route joins — a first-class, discoverable spelling - /// for bundle membership. Accepts a bare string or a list: - /// - /// ```yaml - /// groups: hr-tools # single - /// groups: [hr-tools, pii] # multiple - /// ``` - /// - /// **Pure sugar over tags.** Each named group is folded into the route's - /// tag set at resolution, so `groups: [hr-tools]` and - /// `meta: { tags: [hr-tools] }` resolve identically. Tags remain the - /// substrate — they can also be injected by the host at runtime and carry - /// metadata beyond membership; `groups:` just names the common "join this - /// bundle" case up front. See [`route_static_tags`]. - #[serde(default)] - pub groups: Option, - - /// Conditional match expression — carried but not evaluated - /// during static resolution. Evaluated at runtime when payload - /// data is available (future: APL evaluator). - #[serde(default)] - pub when: Option, - - /// Plugin references to activate for this route. - #[serde(default, deserialize_with = "deserialize_plugin_refs")] - pub plugins: Vec, - - /// Authentication dispatch list for this route (YAML key - /// `authentication:`). **Hook-specific**: applies ONLY to the - /// `identity.resolve` hook, independent of the `plugins:` block above - /// (which is hook-agnostic and means different things depending on - /// whether APL is annotating the route — `authentication:` always - /// means "these plugins fire on identity.resolve in this order"). - /// - /// Accepts two YAML shapes; both deserialize to the same IR. - /// See `crate::identity::route_config::RouteIdentityConfig`. - /// - /// ```yaml - /// # List form — common case, additive default - /// authentication: - /// - corp-jwt - /// - spiffe-attestor - /// - /// # Object form — when the override flag is needed - /// authentication: - /// replace_inherited: true - /// steps: - /// - legacy-basic-auth - /// ``` - #[serde( - default, - rename = "authentication", - deserialize_with = "deserialize_route_identity" - )] - pub identity: Option, -} - -/// Deserialize the `authentication:` block in a `RouteEntry`. Accepts either a YAML -/// list (treated as additive — `replace_inherited: false`) or a -/// YAML map with `replace_inherited: bool?` + `steps: [...]`. Each -/// step is either a bare plugin name (string) or a map with -/// `name:` + optional `on_error:` / `config:`. Produces friendlier -/// error messages than `#[serde(untagged)]` would. -fn deserialize_route_identity<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - use crate::identity::RouteIdentityConfig; - use serde::de::Error; - - // Two-stage: deserialize as opaque YAML so we can discriminate - // list vs object shape with operator-friendly errors. - let raw = match Option::::deserialize(deserializer)? { - None => return Ok(None), - Some(serde_yaml::Value::Null) => return Ok(None), - Some(v) => v, - }; - - let (replace_inherited, raw_steps): (bool, Vec) = match raw { - serde_yaml::Value::Sequence(items) => (false, items), - serde_yaml::Value::Mapping(map) => { - let replace_inherited = - match map.get(serde_yaml::Value::String("replace_inherited".to_string())) { - Some(v) => v.as_bool().ok_or_else(|| { - D::Error::custom("`identity.replace_inherited` must be a boolean") - })?, - None => false, - }; - let steps_val = map - .get(serde_yaml::Value::String("steps".to_string())) - .ok_or_else(|| { - D::Error::custom( - "`authentication:` object form requires `steps:` (a list of \ - authentication steps); did you mean to write the list form?", - ) - })?; - let items = steps_val - .as_sequence() - .ok_or_else(|| D::Error::custom("`authentication.steps` must be a list"))? - .clone(); - (replace_inherited, items) - }, - _ => { - return Err(D::Error::custom( - "`authentication:` must be a list of steps or an object with \ - `steps:` (and optional `replace_inherited:`)", - )); - }, - }; - - let mut steps = Vec::with_capacity(raw_steps.len()); - for (i, raw) in raw_steps.into_iter().enumerate() { - steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?); - } - - Ok(Some(RouteIdentityConfig { - steps, - replace_inherited, - })) -} - -/// Parse one identity step from raw YAML. Accepts either a bare -/// plugin name (string) or a map with `name:` + optional -/// `on_error:` / `config:` (and any forward-compat extras). -fn parse_identity_step( - raw: serde_yaml::Value, - index: usize, -) -> Result { - use crate::identity::RouteIdentityStep; - - match raw { - serde_yaml::Value::String(name) => { - if name.is_empty() { - return Err(format!( - "identity step [{index}] plugin name cannot be empty" - )); - } - Ok(RouteIdentityStep { - name, - ..Default::default() - }) - }, - serde_yaml::Value::Mapping(_) => { - // Lean on serde's derived Deserialize for the map shape — - // `RouteIdentityStep` already handles `name` / `on_error` / - // `config_override` and flattens extras into `extra`. - // Translate the operator-facing key `config` → IR field - // `config_override` (the IR uses a more explicit name to - // distinguish from the plugin's runtime config). - #[derive(serde::Deserialize)] - struct StepYaml { - name: String, - #[serde(default)] - on_error: Option, - #[serde(default)] - config: Option, - #[serde(default, flatten)] - extra: std::collections::HashMap, - } - let parsed: StepYaml = - serde_yaml::from_value(raw).map_err(|e| format!("identity step [{index}]: {e}"))?; - if parsed.name.is_empty() { - return Err(format!("identity step [{index}] `name:` cannot be empty")); - } - Ok(RouteIdentityStep { - name: parsed.name, - config_override: parsed.config, - on_error: parsed.on_error, - extra: parsed.extra, - }) - }, - _ => Err(format!( - "identity step [{index}] must be a plugin name (string) or a map \ - with `name:` (and optional `on_error:` / `config:`)" - )), - } -} - -/// Operational metadata on a route entry. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RouteMeta { - /// Entity tags — drive policy group inheritance. - #[serde(default)] - pub tags: Vec, - - /// Host-defined grouping (virtual server ID, namespace, etc.). - /// Used for scope matching: route scope must match request scope. - #[serde(default)] - pub scope: Option, - - /// Arbitrary key-value metadata. - #[serde(default)] - pub properties: HashMap, -} - -/// An entity-name pattern. Holds the original pattern string (for -/// serialization round-tripping and operator-facing diagnostics) plus a -/// `WildMatch` matcher pre-compiled at deserialize time so route resolution -/// doesn't re-parse the pattern on every request. Custom `Serialize` / -/// `Deserialize` make this transparent to YAML — it serializes as a plain -/// string, just like the previous `String` field did. -/// -/// Glob syntax (via `wildmatch`): -/// - `*` matches any sequence of characters (including empty). -/// - `?` matches any single character. -/// -/// The previous hand-rolled matcher only handled trailing-`*` correctly: -/// `*suffix` patterns silently matched almost nothing, and multi-star -/// patterns like `**` accidentally matched everything. Both shapes are -/// real security footguns for scope/tool restriction rules — switching to -/// `wildmatch` gives us full single-segment glob semantics. -#[derive(Debug, Clone)] -pub struct Pattern { - pattern: String, - matcher: wildmatch::WildMatch, -} - -impl Pattern { - /// Compile a pattern. Done once at config load; subsequent `matches()` - /// calls reuse the compiled `WildMatch`. - pub fn new(pattern: impl Into) -> Self { - let pattern = pattern.into(); - let matcher = wildmatch::WildMatch::new(&pattern); - Self { pattern, matcher } - } - - /// Match the given name against the compiled pattern. - pub fn matches(&self, name: &str) -> bool { - self.matcher.matches(name) - } - - /// The original pattern string (e.g., `"hr-*"`). - pub fn as_str(&self) -> &str { - &self.pattern - } -} - -impl Default for Pattern { - fn default() -> Self { - Self::new("") - } -} - -impl Serialize for Pattern { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.pattern) - } -} - -impl<'de> Deserialize<'de> for Pattern { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Ok(Pattern::new(s)) - } -} - -/// A tool matcher — single name, list of names, or glob pattern. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StringOrList { - /// Single string (exact name or glob pattern). Pre-compiled at - /// deserialize time so the route-resolution slow path doesn't re-parse - /// on each request. - Single(Pattern), - /// List of exact names. - List(Vec), -} - -impl Default for StringOrList { - fn default() -> Self { - Self::Single(Pattern::default()) - } -} - -impl StringOrList { - /// Check if this matcher matches the given name. - pub fn matches(&self, name: &str) -> bool { - match self { - Self::Single(pattern) => pattern.matches(name), - Self::List(names) => names.iter().any(|n| n == name), - } - } - - /// The literal values as written — the pattern string for `Single`, each - /// element for `List`. Use where the values are exact names rather than - /// globs to match against (e.g. group membership, which joins a bundle by - /// its exact name). - pub fn as_names(&self) -> Vec<&str> { - match self { - Self::Single(pattern) => vec![pattern.as_str()], - Self::List(names) => names.iter().map(String::as_str).collect(), - } - } -} - -/// Load and parse a CPEX config from a YAML file. -pub fn load_config(path: &Path) -> Result> { - let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config { - message: format!("failed to read config file '{}': {}", path.display(), e), - })?; - parse_config(&content) -} - -/// Parse a CPEX config from a YAML string. -pub fn parse_config(yaml: &str) -> Result> { - // Scan the raw YAML for renamed legacy keys before the typed parse: - // `RouteEntry` / `GlobalConfig` / `PolicyGroup` silently ignore unknown - // fields, so a stale `identity:` would otherwise be dropped and its - // authentication steps never run — a fail-open. - let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { - message: format!("failed to parse config YAML: {}", e), - })?; - reject_renamed_identity_key(&raw)?; - let mut config: CpexConfig = serde_yaml::from_value(raw).map_err(|e| PluginError::Config { - message: format!("failed to parse config YAML: {}", e), - })?; - merge_groups_into_policies(&mut config); - validate_config(&config)?; - Ok(config) -} - -/// Fold the canonical top-level `groups:` bundles into the internal -/// `global.policies` map (the deprecated alias location), so every resolver -/// can keep reading a single map. Top-level entries win on a name collision -/// — the canonical spelling takes precedence over the deprecated one. -pub(crate) fn merge_groups_into_policies(config: &mut CpexConfig) { - if config.groups.is_empty() { - return; - } - for (name, group) in std::mem::take(&mut config.groups) { - config.global.policies.insert(name, group); - } -} - -/// Reject the pre-rename `identity:` key (now `authentication:`) at every -/// scope it could appear — `global`, `global.policies.`, -/// `global.defaults.`, and each `routes[]` entry — so a stale config -/// fails loudly rather than silently dropping its authentication steps. -pub(crate) fn reject_renamed_identity_key(raw: &serde_yaml::Value) -> Result<(), Box> { - fn renamed(scope: &str) -> Box { - Box::new(PluginError::Config { - message: format!( - "in `{scope}`: config field `identity` was renamed to `authentication` — update your config" - ), - }) - } - if let Some(global) = raw.get("global") { - if global.get("identity").is_some() { - return Err(renamed("global")); - } - for section in ["policies", "defaults"] { - if let Some(map) = global.get(section).and_then(|m| m.as_mapping()) { - for (name, group) in map { - if group.get("identity").is_some() { - let n = name.as_str().unwrap_or("?"); - return Err(renamed(&format!("global.{section}.{n}"))); - } - } - } - } - } - // Same guard for the canonical top-level `groups:` bundle location. - if let Some(map) = raw.get("groups").and_then(|m| m.as_mapping()) { - for (name, group) in map { - if group.get("identity").is_some() { - let n = name.as_str().unwrap_or("?"); - return Err(renamed(&format!("groups.{n}"))); - } - } - } - if let Some(routes) = raw.get("routes").and_then(|r| r.as_sequence()) { - for (i, route) in routes.iter().enumerate() { - if route.get("identity").is_some() { - return Err(renamed(&format!("routes[{i}]"))); - } - } - } - Ok(()) -} - -/// Validate a parsed config for structural correctness. -/// -/// This checks only the *structural* plugin activation lists -/// (`route.plugins` / `policy_group.plugins` sequences). It deliberately -/// does NOT validate APL plugin references — neither `plugin(...)` / `run(...)` -/// policy steps nor the APL per-plugin override *map* (which -/// [`deserialize_plugin_refs`] folds into an empty structural `Vec`, leaving -/// it for the APL visitor to consume). Those are resolved and validated at -/// dispatch-plan build time, where an unknown or unreferenced plugin is logged -/// and skipped (see `apl-cpex::dispatch_plan`). Keeping cpex-core's validation -/// free of APL semantics is intentional. -pub(crate) fn validate_config(config: &CpexConfig) -> Result<(), Box> { - let mut seen_names = HashSet::new(); - for plugin in &config.plugins { - if !seen_names.insert(&plugin.name) { - return Err(Box::new(PluginError::Config { - message: format!("duplicate plugin name: '{}'", plugin.name), - })); - } - } - - if config.routing_enabled() { - let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect(); - - for (i, route) in config.routes.iter().enumerate() { - let count = [ - route.tool.is_some(), - route.resource.is_some(), - route.prompt.is_some(), - route.llm.is_some(), - ] - .iter() - .filter(|&&m| m) - .count(); - - if count == 0 { - return Err(Box::new(PluginError::Config { - message: format!( - "route {} has no entity matcher (need tool, resource, prompt, or llm)", - i - ), - })); - } - if count > 1 { - return Err(Box::new(PluginError::Config { - message: format!( - "route {} has multiple entity matchers (need exactly one)", - i - ), - })); - } - - for plugin_ref in &route.plugins { - if !plugin_names.contains(plugin_ref.name()) { - return Err(Box::new(PluginError::Config { - message: format!( - "route {} references unknown plugin '{}'", - i, - plugin_ref.name() - ), - })); - } - } - - // Validate the first-class `groups:` membership field: a value - // naming no defined group is a typo, and silently ignoring it - // can leave the route without the `authentication:` the group - // would have supplied. `meta.tags` stays permissive — tags are - // an open-ended, host-injectable substrate, not all of which - // name groups. Runs after `merge_groups_into_policies`, so - // top-level `groups:` are already folded into `global.policies`. - if let Some(groups) = &route.groups { - for name in groups.as_names() { - if !config.global.policies.contains_key(name) { - return Err(Box::new(PluginError::Config { - message: format!("route {i} joins unknown group '{name}'"), - })); - } - } - } - } - - for (group_name, group) in &config.global.policies { - for plugin_ref in &group.plugins { - if !plugin_names.contains(plugin_ref.name()) { - return Err(Box::new(PluginError::Config { - message: format!( - "policy group '{}' references unknown plugin '{}'", - group_name, - plugin_ref.name() - ), - })); - } - } - } - } - - Ok(()) -} - -/// Specificity scores for route matching. -const SPECIFICITY_EXACT_NAME: usize = 1000; -const SPECIFICITY_NAME_LIST: usize = 500; -const SPECIFICITY_GLOB: usize = 300; -const SPECIFICITY_WHEN_ONLY: usize = 10; -const SPECIFICITY_WILDCARD: usize = 0; - -/// Score a single entity matcher (tool / resource / prompt / llm) against -/// a request entity name, returning the specificity bucket if it matches -/// or `None` if it doesn't (or the matcher is absent). Replaces four -/// copy-pasted match arms in `resolve_plugins_for_entity`. -fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option { - let matcher = matcher?; - if !matcher.matches(entity_name) { - return None; - } - let score = match matcher { - StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - }; - Some(score) -} - -/// The static bundle-membership tags a route declares: its `meta.tags` -/// plus any `groups:` sugar, unified into one stream. `groups:` is only a -/// discoverable spelling for the common "join this bundle" case — it -/// desugars here into the same tag set the resolvers already match against -/// bundle names, so tags stay the single substrate (runtime-injectable and -/// metadata-bearing). Both membership resolvers iterate this so neither can -/// forget one of the two spellings. -fn route_static_tags(route: &RouteEntry) -> impl Iterator { - let meta_tags = route - .meta - .iter() - .flat_map(|m| m.tags.iter().map(String::as_str)); - let group_tags = route.groups.iter().flat_map(StringOrList::as_names); - meta_tags.chain(group_tags) -} - -/// Resolve which plugins should fire for a given entity. -/// -/// When routing is disabled, returns all plugin names. When enabled, -/// matches the entity against routes and collects plugins from the -/// `all` group, defaults, matching groups (via merged tags), and the -/// route itself. -/// -/// `request_scope` and `request_tags` come from the host's -/// `MetaExtension` on the request. -pub fn resolve_plugins_for_entity( - config: &CpexConfig, - entity_type: &str, - entity_name: &str, - request_scope: Option<&str>, - request_tags: &HashSet, -) -> Vec { - if !config.routing_enabled() { - return config - .plugins - .iter() - .map(|p| ResolvedPlugin { - name: p.name.clone(), - config_overrides: None, - when: None, - }) - .collect(); - } - - let mut resolved = Vec::new(); - - // 1. Always include plugins from the "all" policy group - if let Some(all_group) = config.global.policies.get("all") { - collect_plugin_refs(&all_group.plugins, &mut resolved, None); - } - - // 2. Include plugins from matching defaults - if let Some(default_group) = config.global.defaults.get(entity_type) { - collect_plugin_refs(&default_group.plugins, &mut resolved, None); - } - - // 3. Find matching route (with scope check) - if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) { - // Merge tags: route's static membership (meta.tags + groups: sugar) - // + host's runtime tags. - let mut merged_tags: HashSet = request_tags.clone(); - for tag in route_static_tags(route) { - merged_tags.insert(tag.to_owned()); - } - - // Include plugins from all matching policy groups (merged tags) - for tag in &merged_tags { - if tag == "all" { - continue; // already handled above - } - if let Some(group) = config.global.policies.get(tag.as_str()) { - collect_plugin_refs(&group.plugins, &mut resolved, None); - } - } - - // Include route-level plugins, carrying the route's when clause - collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref()); - } - - // Deduplicate by name, preserving order. Later overrides win. - let mut seen = HashSet::new(); - let mut deduped = Vec::new(); - for rp in resolved.into_iter().rev() { - if seen.insert(rp.name.clone()) { - deduped.push(rp); - } - } - deduped.reverse(); - deduped -} - -/// Resolve the identity-resolve dispatch list for a specific -/// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`] -/// — consults the global `authentication:` block, tag-bundle -/// `authentication:` blocks, and the route's own `authentication:` block -/// to determine which plugins fire on the `identity.resolve` hook for -/// this route. -/// -/// # Inheritance / merge order -/// -/// Layers are stacked **global → tag bundles → route**, in that -/// order. Within tags, the order is determined by the request's -/// `meta.tags` (which combines static route tags + runtime request -/// tags). Each layer is appended to the running list unless the -/// **route's** block has `replace_inherited: true`, in which case -/// inherited layers (global + tags) are dropped and only the route's -/// steps remain. Tag-bundle `replace_inherited` is parsed but not -/// honored — only the route layer can opt out of inheritance. -/// -/// Order matters: returned plugins fire in the order they were -/// merged. The first plugin's resolved `IdentityPayload` flows into -/// the second plugin's input via the executor's Sequential-phase -/// semantics, so global identity contributions land first, then -/// tag-bundle, then route-specific overrides / additions. -/// -/// Per-step `config_override` is surfaced as -/// `ResolvedPlugin.config_overrides` so the standard -/// `filter_entries_by_route` override pathway -/// (`create_override_instance`) applies — same mechanism the -/// `plugins:` block uses. -/// -/// Returns an empty `Vec` when no layer contributed any steps -/// (e.g. anonymous routes that explicitly opt out via -/// `replace_inherited: true` + empty `steps: []`). -pub fn resolve_identity_plugins_for_route( - config: &CpexConfig, - entity_type: &str, - entity_name: &str, - request_scope: Option<&str>, -) -> Vec { - // Route-level block is the override authority. Find the matching - // route up-front; absence means there's no route to inherit - // identity FOR (still consult global identity though, since the - // host might be doing per-route hook routing on entity_type - // alone with no specific route). - let route = find_matching_route(config, entity_type, entity_name, request_scope); - let route_identity = route.and_then(|r| r.identity.as_ref()); - - // Check the override flag before doing any inheritance work — - // if the route opts out, inherited layers are dropped. - let replace_inherited = route_identity - .map(|id| id.replace_inherited) - .unwrap_or(false); - - let mut steps: Vec = Vec::new(); - - if !replace_inherited { - // Global layer first — applies to every route. - if let Some(global_identity) = config.global.identity.as_ref() { - steps.extend(global_identity.steps.iter().cloned()); - } - - // Tag-bundle layers next. Walk the route's static membership tags - // (meta.tags + groups: sugar, via `route_static_tags`). Runtime tags - // would compose here too, but resolve_* currently doesn't take them as - // a parameter for identity — symmetry with the existing `plugins:` - // resolver would extend the signature; deferred until needed. - if let Some(route) = route { - for tag in route_static_tags(route) { - if let Some(bundle) = config.global.policies.get(tag) { - if let Some(bundle_identity) = bundle.identity.as_ref() { - steps.extend(bundle_identity.steps.iter().cloned()); - } - } - } - } - } - - // Route layer last (or only, when replace_inherited). - if let Some(id) = route_identity { - steps.extend(id.steps.iter().cloned()); - } - - steps - .into_iter() - .map(|step| ResolvedPlugin { - name: step.name.clone(), - // Surface config_override under the `config:` key shape - // that `create_override_instance` already understands — - // it reads `overrides.get("config")` to find the merge - // target. Wrapping like this avoids a special-case path. - config_overrides: step.config_override.as_ref().map(|cfg| { - let mut wrapper = serde_json::Map::new(); - wrapper.insert("config".to_string(), cfg.clone()); - serde_json::Value::Object(wrapper) - }), - when: None, - }) - .collect() -} - -/// A resolved plugin with optional config overrides and when clause. -#[derive(Debug, Clone)] -pub struct ResolvedPlugin { - /// Plugin name. - pub name: String, - - /// Config overrides from the route. - pub config_overrides: Option, - - /// When clause from the route — carried but not evaluated here. - pub when: Option, -} - -/// Collect plugin refs into the resolved list. -fn collect_plugin_refs( - refs: &[PluginRouteRef], - resolved: &mut Vec, - route_when: Option<&str>, -) { - for plugin_ref in refs { - resolved.push(ResolvedPlugin { - name: plugin_ref.name().to_string(), - config_overrides: plugin_ref.overrides().cloned(), - when: route_when.map(String::from), - }); - } -} - -/// Find the best matching route for an entity by specificity. -/// -/// Scope matching: if a route declares a scope, the request must -/// have the same scope. No scope on the route matches any request. -fn find_matching_route<'a>( - config: &'a CpexConfig, - entity_type: &str, - entity_name: &str, - request_scope: Option<&str>, -) -> Option<&'a RouteEntry> { - let mut best: Option<(usize, &RouteEntry)> = None; - - for route in &config.routes { - let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref()); - let scope_bonus = match (route_scope, request_scope) { - (None, _) => 0, // route is global - (Some(rs), Some(rq)) if rs == rq => 100, // scopes match - (Some(_), _) => continue, // scope mismatch — skip - }; - - let entity_matcher = match entity_type { - "tool" => route.tool.as_ref(), - "resource" => route.resource.as_ref(), - "prompt" => route.prompt.as_ref(), - "llm" => route.llm.as_ref(), - _ => continue, - }; - let base_specificity = match score_entity_match(entity_matcher, entity_name) { - Some(score) => score, - None => continue, - }; - - let when_bonus = if route.when.is_some() { - SPECIFICITY_WHEN_ONLY - } else { - 0 - }; - let total = base_specificity + scope_bonus + when_bonus; - - if best.is_none_or(|(s, _)| total > s) { - best = Some((total, route)); - } - } - - best.map(|(_, route)| route) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Helper: empty tags for tests that don't need them - fn no_tags() -> HashSet { - HashSet::new() - } - - #[test] - fn test_parse_minimal_config() { - let yaml = r#" -plugins: - - name: rate_limiter - kind: builtin - hooks: [tool_pre_invoke] - mode: sequential - priority: 5 - config: - max_requests: 100 -"#; - let config = parse_config(yaml).unwrap(); - assert!(!config.routing_enabled()); - assert_eq!(config.plugins.len(), 1); - assert_eq!(config.plugins[0].name, "rate_limiter"); - } - - #[test] - fn test_no_plugin_settings_defaults_routing_disabled() { - let yaml = r#" -plugins: - - name: test - kind: builtin - hooks: [tool_pre_invoke] -"#; - let config = parse_config(yaml).unwrap(); - assert!(!config.routing_enabled()); - assert_eq!(config.plugin_settings.plugin_timeout, 30); - } - - #[test] - fn test_routing_enabled() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [identity] -plugins: - - name: identity - kind: builtin - hooks: [identity_resolve] -routes: - - tool: get_compensation - meta: - tags: [pii] -"#; - let config = parse_config(yaml).unwrap(); - assert!(config.routing_enabled()); - } - - #[test] - fn test_duplicate_plugin_names_rejected() { - let yaml = r#" -plugins: - - name: dup - kind: builtin - hooks: [tool_pre_invoke] - - name: dup - kind: builtin - hooks: [tool_post_invoke] -"#; - assert!(parse_config(yaml) - .unwrap_err() - .to_string() - .contains("duplicate plugin name")); - } - - #[test] - fn test_route_requires_one_entity_matcher() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: [] -routes: - - meta: - tags: [pii] -"#; - assert!(parse_config(yaml) - .unwrap_err() - .to_string() - .contains("no entity matcher")); - } - - #[test] - fn test_route_rejects_multiple_entity_matchers() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: [] -routes: - - tool: get_compensation - resource: "hr://employees/*" -"#; - assert!(parse_config(yaml) - .unwrap_err() - .to_string() - .contains("multiple entity matchers")); - } - - #[test] - fn test_route_unknown_plugin_rejected() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: known - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - plugins: - - unknown -"#; - assert!(parse_config(yaml) - .unwrap_err() - .to_string() - .contains("unknown plugin 'unknown'")); - } - - #[test] - fn test_policy_group_unknown_plugin_rejected() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [nonexistent] -plugins: [] -routes: [] -"#; - assert!(parse_config(yaml) - .unwrap_err() - .to_string() - .contains("unknown plugin 'nonexistent'")); - } - - #[test] - fn test_resolve_conditions_mode_returns_all() { - let yaml = r#" -plugins: - - name: a - kind: builtin - hooks: [tool_pre_invoke] - - name: b - kind: builtin - hooks: [tool_post_invoke] -"#; - let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags()); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["a", "b"]); - } - - #[test] - fn test_resolve_routes_inherits_policy_groups() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: - - identity - pii: - plugins: - - apl_policy -plugins: - - name: identity - kind: builtin - hooks: [identity_resolve] - - name: apl_policy - kind: builtin - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_compensation - meta: - tags: [pii] -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"identity")); - assert!(names.contains(&"apl_policy")); - } - - #[test] - fn test_resolve_no_matching_route_gets_all_only() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: - - identity -plugins: - - name: identity - kind: builtin - hooks: [identity_resolve] -routes: - - tool: get_compensation -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags()); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["identity"]); - } - - #[test] - fn test_exact_match_beats_glob() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: specific - kind: builtin - hooks: [tool_pre_invoke] - - name: general - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: "hr-*" - plugins: - - general - - tool: hr-compensation - plugins: - - specific -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags()); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"specific")); - assert!(!names.contains(&"general")); - } - - #[test] - fn test_plugin_ref_bare_name() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: rate_limiter - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - plugins: - - rate_limiter -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - assert_eq!(resolved[0].name, "rate_limiter"); - assert!(resolved[0].config_overrides.is_none()); - } - - #[test] - fn test_plugin_ref_with_overrides() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: rate_limiter - kind: builtin - hooks: [tool_pre_invoke] - config: - max_requests: 100 -routes: - - tool: get_compensation - plugins: - - rate_limiter: - config: - max_requests: 10 -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - assert_eq!(resolved[0].name, "rate_limiter"); - assert!(resolved[0].config_overrides.is_some()); - let overrides = resolved[0].config_overrides.as_ref().unwrap(); - assert_eq!(overrides["config"]["max_requests"], 10); - } - - #[test] - fn test_plugin_ref_mixed_bare_and_overrides() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: rate_limiter - kind: builtin - hooks: [tool_pre_invoke] - - name: pii_scanner - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - plugins: - - rate_limiter - - pii_scanner: - config: - sensitivity: high -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - assert_eq!(resolved.len(), 2); - assert_eq!(resolved[0].name, "rate_limiter"); - assert!(resolved[0].config_overrides.is_none()); - assert_eq!(resolved[1].name, "pii_scanner"); - assert!(resolved[1].config_overrides.is_some()); - } - - #[test] - fn test_deduplication_preserves_order() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [a, b] - pii: - plugins: [b, c] -plugins: - - name: a - kind: builtin - hooks: [tool_pre_invoke] - - name: b - kind: builtin - hooks: [tool_pre_invoke] - - name: c - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - meta: - tags: [pii] -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["a", "b", "c"]); - } - - #[test] - fn test_glob_trailing_wildcard() { - let matcher = StringOrList::Single(Pattern::new("hr-*")); - assert!(matcher.matches("hr-compensation")); - assert!(matcher.matches("hr-benefits")); - assert!(matcher.matches("hr-")); // empty match for * - assert!(!matcher.matches("finance-report")); - assert!(!matcher.matches("hr")); - } - - #[test] - fn test_wildcard_matches_everything() { - let matcher = StringOrList::Single(Pattern::new("*")); - assert!(matcher.matches("anything")); - assert!(matcher.matches("")); - } - - /// Regression for the security footgun: `*suffix` patterns were - /// silently matching almost nothing because the previous matcher - /// looked for `"*suffix"` as a literal prefix. - #[test] - fn test_glob_leading_wildcard() { - let matcher = StringOrList::Single(Pattern::new("*-prod")); - assert!(matcher.matches("foo-prod")); - assert!(matcher.matches("-prod")); // empty match for * - assert!(!matcher.matches("foo-staging")); - assert!(!matcher.matches("prod")); - } - - /// Regression for `prefix*suffix` patterns also broken before. - #[test] - fn test_glob_mid_wildcard() { - let matcher = StringOrList::Single(Pattern::new("hr-*-v1")); - assert!(matcher.matches("hr-comp-v1")); - assert!(matcher.matches("hr--v1")); // empty match for * - assert!(!matcher.matches("hr-comp-v2")); - assert!(!matcher.matches("finance-comp-v1")); - } - - /// Multiple-wildcard patterns must work everywhere `*` appears. - #[test] - fn test_glob_multiple_wildcards() { - let matcher = StringOrList::Single(Pattern::new("*hr*comp*")); - assert!(matcher.matches("hr-comp")); - assert!(matcher.matches("xyz-hr-comp-foo")); - assert!(!matcher.matches("hr-only")); - assert!(!matcher.matches("comp-only")); - } - - /// Regression for the OTHER security footgun: multi-star patterns - /// like `**` were `trim_end_matches('*')`'d to `""` and then matched - /// every name via `starts_with("")`. With wildmatch this is a - /// degenerate-but-correct "match anything" pattern, equivalent to `*`. - #[test] - fn test_glob_multi_star_is_equivalent_to_single_star() { - for pattern in &["**", "***", "*****"] { - let matcher = StringOrList::Single(Pattern::new(*pattern)); - assert!( - matcher.matches("anything"), - "pattern {} should match", - pattern - ); - assert!( - matcher.matches(""), - "pattern {} should match empty", - pattern - ); - } - } - - /// `WildMatch` is built once at deserialize / `Pattern::new` time and - /// reused; this test just sanity-checks the round-trip through serde. - #[test] - fn test_pattern_round_trips_through_yaml() { - let yaml = "tool: '*-prod'"; - #[derive(Deserialize, Serialize)] - struct Wrap { - tool: StringOrList, - } - let parsed: Wrap = serde_yaml::from_str(yaml).unwrap(); - assert!(parsed.tool.matches("foo-prod")); - assert!(!parsed.tool.matches("foo-staging")); - let back = serde_yaml::to_string(&parsed).unwrap(); - assert!( - back.contains("*-prod"), - "serialized YAML should preserve pattern: {}", - back - ); - } - - #[test] - fn test_list_matches_any_member() { - let matcher = StringOrList::List(vec![ - "get_compensation".to_string(), - "get_benefits".to_string(), - ]); - assert!(matcher.matches("get_compensation")); - assert!(matcher.matches("get_benefits")); - assert!(!matcher.matches("send_email")); - } - - #[test] - fn test_validation_skipped_when_routing_disabled() { - let yaml = r#" -plugins: - - name: test - kind: builtin - hooks: [tool_pre_invoke] -routes: - - meta: - tags: [pii] -"#; - let config = parse_config(yaml); - assert!(config.is_ok()); - } - - // -- Scope matching tests -- - - #[test] - fn test_scope_match_selects_scoped_route() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: scoped_plugin - kind: builtin - hooks: [tool_pre_invoke] - - name: global_plugin - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - meta: - scope: hr-services - plugins: - - scoped_plugin - - tool: get_compensation - plugins: - - global_plugin -"#; - let config = parse_config(yaml).unwrap(); - - // With matching scope — scoped route wins (more specific) - let resolved = resolve_plugins_for_entity( - &config, - "tool", - "get_compensation", - Some("hr-services"), - &no_tags(), - ); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"scoped_plugin")); - assert!(!names.contains(&"global_plugin")); - - // Without scope — global route matches - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"global_plugin")); - assert!(!names.contains(&"scoped_plugin")); - - // With different scope — global route matches (scoped doesn't) - let resolved = resolve_plugins_for_entity( - &config, - "tool", - "get_compensation", - Some("billing"), - &no_tags(), - ); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"global_plugin")); - assert!(!names.contains(&"scoped_plugin")); - } - - // -- Tag merging tests -- - - #[test] - fn test_host_tags_merged_with_route_tags() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - pii: - plugins: [pii_plugin] - runtime_tag: - plugins: [runtime_plugin] -plugins: - - name: pii_plugin - kind: builtin - hooks: [tool_pre_invoke] - - name: runtime_plugin - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - meta: - tags: [pii] -"#; - let config = parse_config(yaml).unwrap(); - - // Host provides a runtime tag that matches a policy group - let mut host_tags = HashSet::new(); - host_tags.insert("runtime_tag".to_string()); - - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - - // Both route's static tag (pii) and host's runtime tag activate their groups - assert!(names.contains(&"pii_plugin")); - assert!(names.contains(&"runtime_plugin")); - } - - // -- When clause carried tests -- - - #[test] - fn test_when_clause_carried_on_resolved_plugins() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: conditional_plugin - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - when: "args.include_ssn == true" - plugins: - - conditional_plugin -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - assert_eq!(resolved[0].name, "conditional_plugin"); - assert_eq!( - resolved[0].when.as_deref(), - Some("args.include_ssn == true") - ); - } - - #[test] - fn test_when_clause_not_on_policy_group_plugins() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [global_plugin] -plugins: - - name: global_plugin - kind: builtin - hooks: [tool_pre_invoke] - - name: route_plugin - kind: builtin - hooks: [tool_pre_invoke] -routes: - - tool: get_compensation - when: "args.sensitive == true" - plugins: - - route_plugin -"#; - let config = parse_config(yaml).unwrap(); - let resolved = - resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); - - // global_plugin has no when clause (from all group) - let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap(); - assert!(global.when.is_none()); - - // route_plugin carries the route's when clause - let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap(); - assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); - } - - #[test] - fn parse_route_identity_list_form() { - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - authentication: - - corp-jwt - - spiffe-attestor -"#; - let cfg = parse_config(yaml).unwrap(); - let route = &cfg.routes[0]; - let id = route.identity.as_ref().expect("identity present"); - assert!(!id.replace_inherited); - assert_eq!(id.steps.len(), 2); - assert_eq!(id.steps[0].name, "corp-jwt"); - assert!(id.steps[0].config_override.is_none()); - assert!(id.steps[0].on_error.is_none()); - assert_eq!(id.steps[1].name, "spiffe-attestor"); - } - - #[test] - fn parse_route_identity_object_form_carries_replace_inherited() { - let yaml = r#" -plugins: - - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: legacy - authentication: - replace_inherited: true - steps: - - legacy-basic-auth -"#; - let cfg = parse_config(yaml).unwrap(); - let id = cfg.routes[0].identity.as_ref().unwrap(); - assert!(id.replace_inherited); - assert_eq!(id.steps.len(), 1); - assert_eq!(id.steps[0].name, "legacy-basic-auth"); - } - - #[test] - fn parse_route_identity_map_step_with_on_error_and_config() { - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - authentication: - - name: corp-jwt - on_error: deny - config: - audience: my-tool -"#; - let cfg = parse_config(yaml).unwrap(); - let id = cfg.routes[0].identity.as_ref().unwrap(); - let s0 = &id.steps[0]; - assert_eq!(s0.name, "corp-jwt"); - assert_eq!(s0.on_error.as_deref(), Some("deny")); - let cfg_override = s0.config_override.as_ref().expect("config_override set"); - assert_eq!( - cfg_override.get("audience").and_then(|v| v.as_str()), - Some("my-tool"), - ); - } - - #[test] - fn parse_route_identity_mixed_bare_and_map_steps() { - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - authentication: - - name: corp-jwt - on_error: deny - - spiffe-attestor -"#; - let cfg = parse_config(yaml).unwrap(); - let steps = &cfg.routes[0].identity.as_ref().unwrap().steps; - assert_eq!(steps.len(), 2); - assert_eq!(steps[0].on_error.as_deref(), Some("deny")); - assert!(steps[1].on_error.is_none()); - } - - #[test] - fn parse_route_identity_object_form_without_steps_errors() { - let yaml = r#" -routes: - - tool: bad - authentication: - replace_inherited: true -"#; - let err = parse_config(yaml).expect_err("object form requires steps"); - let msg = format!("{err}"); - assert!(msg.contains("requires `steps:`"), "got: {msg}"); - } - - #[test] - fn parse_route_identity_replace_inherited_must_be_boolean() { - let yaml = r#" -routes: - - tool: bad - authentication: - replace_inherited: "yes" - steps: - - corp-jwt -"#; - let err = parse_config(yaml).expect_err("replace_inherited must be bool"); - let msg = format!("{err}"); - assert!(msg.contains("boolean"), "got: {msg}"); - } - - #[test] - fn parse_route_identity_empty_step_name_errors() { - let yaml = r#" -routes: - - tool: bad - authentication: - - "" -"#; - let err = parse_config(yaml).expect_err("empty step name should fail"); - let msg = format!("{err}"); - assert!(msg.contains("empty"), "got: {msg}"); - } - - #[test] - fn legacy_identity_key_is_rejected_at_route_and_global() { - // The legacy `identity:` key must fail loudly, never be silently - // dropped (which would skip authentication — a fail-open). - for yaml in [ - "routes:\n - tool: t\n identity:\n - corp-jwt\n", - "global:\n identity:\n - corp-jwt\n", - "global:\n policies:\n all:\n identity:\n - corp-jwt\n", - "global:\n defaults:\n tool:\n identity:\n - corp-jwt\n", - ] { - let err = parse_config(yaml).expect_err("legacy identity: must be rejected"); - let msg = format!("{err}"); - assert!( - msg.contains("identity") && msg.contains("authentication"), - "rejection should name the rename: {msg}" - ); - } - } - - #[test] - fn parse_route_identity_scalar_shape_errors() { - let yaml = r#" -routes: - - tool: bad - authentication: 42 -"#; - let err = parse_config(yaml).expect_err("scalar identity should fail"); - let msg = format!("{err}"); - assert!(msg.contains("list of steps"), "got: {msg}"); - } - - #[test] - fn resolve_identity_returns_empty_when_no_route_matches() { - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - authentication: - - corp-jwt -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None); - assert!(resolved.is_empty()); - } - - #[test] - fn resolve_identity_returns_empty_when_route_has_no_identity_block() { - let yaml = r#" -plugins: - - { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] } -routes: - - tool: get_weather - plugins: - - rate_limiter -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); - assert!(resolved.is_empty()); - } - - #[test] - fn resolve_identity_preserves_declared_order() { - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } - - { name: agent-context, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - authentication: - - spiffe-attestor - - corp-jwt - - agent-context -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]); - } - - #[test] - fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() { - // `create_override_instance` reads `overrides.get("config")` - // — `resolve_identity_plugins_for_route` wraps the step's - // `config_override` under that key so the existing override - // pathway picks it up without a special case. - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - authentication: - - name: corp-jwt - config: - audience: my-tool -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); - assert_eq!(resolved.len(), 1); - let overrides = resolved[0] - .config_overrides - .as_ref() - .expect("overrides wrapped"); - let config = overrides.get("config").expect("config key present"); - assert_eq!( - config.get("audience").and_then(|v| v.as_str()), - Some("my-tool") - ); - } - - #[test] - fn resolve_identity_includes_global_layer_when_route_has_no_block() { - // global.identity defined; route declares no identity. The - // route should inherit the global steps unchanged. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } -global: - authentication: - - corp-jwt -routes: - - tool: get_weather -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["corp-jwt"]); - } - - #[test] - fn resolve_identity_appends_route_steps_after_global_by_default() { - // global → route is the standard stacking. Route's `identity:` - // is the list form (implicit replace_inherited=false), so - // its steps APPEND after the global's. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - - { name: agent-context, kind: builtin, hooks: [identity.resolve] } -global: - authentication: - - corp-jwt -routes: - - tool: get_weather - authentication: - - agent-context -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["corp-jwt", "agent-context"]); - } - - #[test] - fn resolve_identity_stacks_global_then_tag_bundle_then_route() { - // Full stack: global + tag bundle + route, all contributing. - // Order is global first, then the matching tag's bundle, - // then the route's own steps. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } - - { name: agent-context, kind: builtin, hooks: [identity.resolve] } -global: - authentication: - - corp-jwt - policies: - finance: - authentication: - - workday-saml -routes: - - tool: get_compensation - meta: - tags: [finance] - authentication: - - agent-context -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]); - } - - #[test] - fn resolve_identity_replace_inherited_drops_global_and_tag_layers() { - // Route says `replace_inherited: true` → only route's steps - // survive. Global and tag-bundle contributions get dropped. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } - - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } -global: - authentication: - - corp-jwt - policies: - finance: - authentication: - - workday-saml -routes: - - tool: legacy_endpoint - meta: - tags: [finance] - authentication: - replace_inherited: true - steps: - - legacy-basic-auth -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None); - let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert_eq!(names, vec!["legacy-basic-auth"]); - } - - #[test] - fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() { - // `replace_inherited: true` + `steps: []` is the explicit - // opt-out — anonymous routes use this to suppress inherited - // identity entirely. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } -global: - authentication: - - corp-jwt -routes: - - tool: anonymous_endpoint - authentication: - replace_inherited: true - steps: [] -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None); - assert!(resolved.is_empty()); - } - - #[test] - fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() { - // The tag bundle's identity only contributes when the route - // declares the matching tag — not for unrelated routes. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } -global: - policies: - finance: - authentication: - - workday-saml -routes: - - tool: with_tag - meta: - tags: [finance] - - tool: without_tag -"#; - let cfg = parse_config(yaml).unwrap(); - - let tagged = resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None); - assert_eq!( - tagged.iter().map(|r| r.name.as_str()).collect::>(), - vec!["workday-saml"], - ); - - let untagged = resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None); - assert!( - untagged.is_empty(), - "tag bundle should NOT apply to untagged routes" - ); - } - - #[test] - fn groups_field_is_sugar_for_meta_tags_in_plugin_resolution() { - // A route's `groups:` joins the same bundle as `meta.tags` — both - // desugar to the same tag set, so resolution is identical. String and - // list forms both work. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: pii-scan, kind: builtin, hooks: [identity.resolve] } -global: - policies: - hr-tools: - plugins: - - pii-scan -routes: - - tool: via_tags - meta: - tags: [hr-tools] - - tool: via_groups_list - groups: [hr-tools] - - tool: via_groups_string - groups: hr-tools -"#; - let cfg = parse_config(yaml).unwrap(); - let no_runtime = HashSet::new(); - let names = |entity: &str| { - resolve_plugins_for_entity(&cfg, "tool", entity, None, &no_runtime) - .iter() - .map(|r| r.name.clone()) - .collect::>() - }; - - assert_eq!(names("via_tags"), vec!["pii-scan"]); - assert_eq!( - names("via_groups_list"), - names("via_tags"), - "groups: [x] must resolve identically to meta.tags: [x]" - ); - assert_eq!( - names("via_groups_string"), - names("via_tags"), - "bare-string groups: x must resolve identically too" - ); - } - - #[test] - fn groups_field_is_sugar_for_meta_tags_in_identity_resolution() { - // The same sugar applies to the identity (authentication) resolver. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } -global: - policies: - finance: - authentication: - - workday-saml -routes: - - tool: via_groups - groups: finance -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "via_groups", None); - assert_eq!( - resolved.iter().map(|r| r.name.as_str()).collect::>(), - vec!["workday-saml"], - "groups: sugar pulls the bundle's authentication just like meta.tags" - ); - } - - #[test] - fn groups_and_meta_tags_compose_as_a_union() { - // A route may carry both spellings; effective membership is the union. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: a, kind: builtin, hooks: [identity.resolve] } - - { name: b, kind: builtin, hooks: [identity.resolve] } -global: - policies: - grp-a: - authentication: [a] - grp-b: - authentication: [b] -routes: - - tool: both - meta: - tags: [grp-a] - groups: [grp-b] -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "both", None); - let names: Vec<_> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!( - names.contains(&"a"), - "meta.tags bundle must apply: {names:?}" - ); - assert!(names.contains(&"b"), "groups bundle must apply: {names:?}"); - } - - #[test] - fn top_level_groups_section_is_the_canonical_bundle_location() { - // Bundles can live at top-level `groups:` (canonical) instead of - // `global.policies:` (deprecated); a route joins one the same way. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } -groups: - finance: - authentication: - - workday-saml -routes: - - tool: pay - groups: finance -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "pay", None); - assert_eq!( - resolved.iter().map(|r| r.name.as_str()).collect::>(), - vec!["workday-saml"], - "a bundle at top-level groups: resolves like one at global.policies:" - ); - } - - #[test] - fn top_level_groups_and_deprecated_global_policies_both_apply() { - // Both locations coexist and contribute; neither shadows the other. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: a, kind: builtin, hooks: [identity.resolve] } - - { name: b, kind: builtin, hooks: [identity.resolve] } -groups: - new-loc: - authentication: [a] -global: - policies: - old-loc: - authentication: [b] -routes: - - tool: mixed - groups: [new-loc, old-loc] -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "mixed", None); - let names: Vec<_> = resolved.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"a"), "top-level groups applies: {names:?}"); - assert!( - names.contains(&"b"), - "deprecated global.policies applies: {names:?}" - ); - } - - #[test] - fn top_level_groups_wins_on_name_collision_with_global_policies() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: canonical, kind: builtin, hooks: [identity.resolve] } - - { name: deprecated, kind: builtin, hooks: [identity.resolve] } -groups: - dup: - authentication: [canonical] -global: - policies: - dup: - authentication: [deprecated] -routes: - - tool: t - groups: dup -"#; - let cfg = parse_config(yaml).unwrap(); - let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "t", None); - assert_eq!( - resolved.iter().map(|r| r.name.as_str()).collect::>(), - vec!["canonical"], - "canonical top-level groups: must win over deprecated global.policies:" - ); - } - - #[test] - fn stale_identity_key_under_top_level_groups_is_rejected() { - // The fail-loud guard extends to the new location. - let yaml = r#" -plugin_settings: - routing_enabled: true -groups: - finance: - identity: - - old-key -"#; - let err = parse_config(yaml).unwrap_err(); - let msg = format!("{err}"); - assert!(msg.contains("groups.finance"), "names the scope: {msg}"); - assert!(msg.contains("authentication"), "mentions the rename: {msg}"); - } - - #[test] - fn route_joining_unknown_group_is_rejected() { - // A typo'd `groups:` value must fail at load, not silently - // leave the route without the group's authentication. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: jwt-hr, kind: identity/jwt, hooks: [identity.resolve] } -groups: - hr-tools: - authentication: [jwt-hr] -routes: - - tool: get_compensation - groups: hr-toolz -"#; - let err = parse_config(yaml).unwrap_err(); - let msg = format!("{err}"); - assert!(msg.contains("unknown group"), "names the error: {msg}"); - assert!(msg.contains("hr-toolz"), "names the typo: {msg}"); - } - - #[test] - fn route_joining_defined_group_passes_validation() { - // Sanity: a correct `groups:` reference (and via meta.tags, which - // stays permissive) validates fine. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - { name: jwt-hr, kind: identity/jwt, hooks: [identity.resolve] } -groups: - hr-tools: - authentication: [jwt-hr] -routes: - - tool: get_compensation - groups: hr-tools - - tool: search_repos - meta: { tags: [some-runtime-tag] } -"#; - assert!(parse_config(yaml).is_ok()); - } - - #[test] - fn resolve_identity_scope_filtering_matches_other_route_resolution() { - // Identity routing uses the same `find_matching_route` - // scope-aware matcher as the generic `plugins:` resolution, - // so requests for a different scope shouldn't pick up - // identity from this route. - let yaml = r#" -plugins: - - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } -routes: - - tool: get_weather - meta: - scope: tenant-a - authentication: - - corp-jwt -"#; - let cfg = parse_config(yaml).unwrap(); - let matching = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-a")); - assert_eq!(matching.len(), 1); - - let non_matching = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-b")); - assert!(non_matching.is_empty()); - } - - fn deserialize_cfg(yaml: &str) -> Result { - serde_yaml::from_str(yaml).map_err(|e| e.to_string()) - } - - #[test] - fn route_plugins_list_parses_as_activation_list() { - let cfg = deserialize_cfg( - r#" -routes: - - tool: get_weather - plugins: - - rate_limiter - - pii_scanner: - config: - sensitivity: high -"#, - ) - .unwrap(); - let plugins = &cfg.routes[0].plugins; - assert_eq!(plugins.len(), 2); - assert_eq!(plugins[0].name(), "rate_limiter"); - assert_eq!(plugins[1].name(), "pii_scanner"); - } - - #[test] - fn route_plugins_map_loads_as_empty_structural_list() { - let cfg = deserialize_cfg( - r#" -routes: - - tool: get_weather - plugins: - audit: - on_error: ignore -"#, - ) - .expect("flat plugins map must deserialize"); - assert!( - cfg.routes[0].plugins.is_empty(), - "a plugins map is APL-override data, not a structural activation list", - ); - } - - #[test] - fn defaults_and_policies_plugins_map_loads() { - let cfg = deserialize_cfg( - r#" -global: - defaults: - tool: - plugins: - audit: - on_error: ignore - policies: - sensitive: - plugins: - pii_scanner: - config: - sensitivity: high -"#, - ) - .expect("defaults/policies plugins map must deserialize"); - assert!(cfg.global.defaults["tool"].plugins.is_empty()); - assert!(cfg.global.policies["sensitive"].plugins.is_empty()); - } - - #[test] - fn scalar_plugins_value_is_rejected_with_clear_error() { - let err = deserialize_cfg( - r#" -routes: - - tool: get_weather - plugins: nonsense -"#, - ) - .expect_err("scalar plugins must error"); - assert!( - err.contains("sequence") && err.contains("mapping"), - "expected a shape-aware error, got: {err}", - ); - } -} diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs deleted file mode 100644 index 474776ec..00000000 --- a/crates/cpex-core/src/context.rs +++ /dev/null @@ -1,187 +0,0 @@ -// Location: ./crates/cpex-core/src/context.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Execution context types. -// -// Provides PluginContext — the per-plugin, per-invocation execution -// context carrying transient state (counters, caches, intermediate -// results). All data needed for policy evaluation comes from the -// payload's extensions (filtered by capabilities), not from context. -// -// PluginContext has two state maps: -// - local_state: private to this plugin, this invocation -// - global_state: shared across plugins in a pipeline -// -// Identity, request metadata, tenant scope, etc. live in extensions -// (MetaExtension, SecurityExtension), not in the context. -// -// Mirrors the spec's PluginContext. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use uuid::Uuid; - -/// Per-plugin, per-invocation execution context. -/// -/// Each plugin receives its own `PluginContext` with: -/// -/// - `local_state` — private to this plugin, this invocation. Fresh -/// each time. Used for per-plugin counters, caches, scratch data. -/// - `global_state` — shared across all plugins in a pipeline. The -/// executor merges changes back after serial phases so subsequent -/// plugins see contributions from earlier ones. -/// -/// All data needed for policy evaluation (identity, tenant, request -/// metadata) comes from the payload's extensions, capability-gated -/// per plugin. Context is purely for transient execution state. -/// -/// ```text -/// PluginContext -/// ├── local_state: HashMap # Per-plugin, per-request. Private. -/// └── global_state: HashMap # Shared across plugins. Use with care. -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginContext { - /// Plugin-local state. Private to this plugin, this invocation. - #[serde(default)] - pub local_state: HashMap, - - /// Shared state across all plugins in the pipeline. - /// The executor merges changes back after each serial-phase plugin. - #[serde(default)] - pub global_state: HashMap, -} - -impl PluginContext { - /// Create a new empty plugin context. - pub fn new() -> Self { - Self { - local_state: HashMap::new(), - global_state: HashMap::new(), - } - } - - /// Create a plugin context with pre-populated global state. - pub fn with_global_state(global_state: HashMap) -> Self { - Self { - local_state: HashMap::new(), - global_state, - } - } - - /// Get a value from local state. - pub fn get_local(&self, key: &str) -> Option<&Value> { - self.local_state.get(key) - } - - /// Set a value in local state. - pub fn set_local(&mut self, key: impl Into, value: Value) { - self.local_state.insert(key.into(), value); - } - - /// Get a value from global state. - pub fn get_global(&self, key: &str) -> Option<&Value> { - self.global_state.get(key) - } - - /// Set a value in global state. - pub fn set_global(&mut self, key: impl Into, value: Value) { - self.global_state.insert(key.into(), value); - } -} - -impl Default for PluginContext { - fn default() -> Self { - Self::new() - } -} - -/// Threaded execution state carried from one hook invocation to the next -/// within a single request lifecycle (e.g., `pre_invoke` → `post_invoke`). -/// -/// The table holds the canonical pipeline state in two parts: -/// -/// - `global_state` — a single shared map across all plugins. The executor -/// clones this into each plugin's `PluginContext.global_state` at the -/// start of a run, then commits the plugin's possibly-modified copy back -/// when the run completes (last-writer-wins for serial phases). -/// - `local_states` — per-plugin private state, indexed by plugin ID. -/// Persists across hook invocations so a plugin's `pre_invoke` can stash -/// data its `post_invoke` will read. -/// -/// Storing `global_state` once (rather than copying it inside every per-plugin -/// `PluginContext`) makes the canonical state explicit and removes the -/// non-deterministic "pick an arbitrary plugin's snapshot" pattern that was -/// previously needed to recover it. -/// -/// Returned by the executor in `PipelineResult` and passed back into the -/// next hook call. On the first hook call pass `None` — the executor -/// creates a fresh table. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct PluginContextTable { - /// Authoritative shared state across all plugins in the pipeline. - #[serde(default)] - pub global_state: HashMap, - - /// Per-plugin local state, indexed by plugin ID (`Uuid`). - #[serde(default)] - pub local_states: HashMap>, -} - -impl PluginContextTable { - /// Create an empty context table. - pub fn new() -> Self { - Self::default() - } - - /// Build a `PluginContext` for the given plugin, *removing* its stored - /// local_state from the table and seeding it with a fresh clone of the - /// canonical global_state. Use in serial phases where the plugin will - /// commit its local_state changes back via [`store_context`]. - /// - /// If the plugin has no stored local_state yet, its context starts - /// empty (first invocation in the request lifecycle). - pub fn take_context(&mut self, plugin_id: Uuid) -> PluginContext { - PluginContext { - local_state: self.local_states.remove(&plugin_id).unwrap_or_default(), - global_state: self.global_state.clone(), - } - } - - /// Build a `PluginContext` for the given plugin without mutating the - /// table — the local_state is *cloned* and the global_state is cloned. - /// Use in read-only phases (audit, concurrent, fire-and-forget) where - /// per-plugin mutations should not influence subsequent plugins. - pub fn snapshot_context(&self, plugin_id: Uuid) -> PluginContext { - PluginContext { - local_state: self - .local_states - .get(&plugin_id) - .cloned() - .unwrap_or_default(), - global_state: self.global_state.clone(), - } - } - - /// Commit a plugin's context back into the table after it ran. Replaces - /// the canonical global_state with the plugin's possibly-modified copy - /// (move, no clone) and stores the plugin's local_state for next time. - pub fn store_context(&mut self, plugin_id: Uuid, ctx: PluginContext) { - self.global_state = ctx.global_state; - self.local_states.insert(plugin_id, ctx.local_state); - } - - /// Number of plugins with stored local_state in the table. - pub fn len(&self) -> usize { - self.local_states.len() - } - - /// Whether the table holds no per-plugin local_state. - pub fn is_empty(&self) -> bool { - self.local_states.is_empty() - } -} diff --git a/crates/cpex-core/src/delegation/hook.rs b/crates/cpex-core/src/delegation/hook.rs deleted file mode 100644 index 9b001514..00000000 --- a/crates/cpex-core/src/delegation/hook.rs +++ /dev/null @@ -1,86 +0,0 @@ -// Location: ./crates/cpex-core/src/delegation/hook.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `TokenDelegateHook` — the `HookTypeDef` marker for the -// TokenDelegate hook family. Plugins implement -// `HookHandler`; outbound code dispatches into it -// to mint a downstream-scoped credential for the call it's about to -// make. -// -// Single hook name (for now): `"token.delegate"`. Future variants -// with the same payload shape — e.g. `"token.refresh"` for a -// refresh-token specific flow — could share `TokenDelegateHook` via -// multi-name registration. Variants with different payloads get -// their own hook type rather than reusing this one. - -use crate::hooks::trait_def::PluginResult; - -use super::payload::DelegationPayload; - -/// Primary hook name for TokenDelegate handlers. -pub const HOOK_TOKEN_DELEGATE: &str = "token.delegate"; - -crate::define_hook! { - /// Token-delegation hook. - /// - /// **Payload** ([`DelegationPayload`]) — unified input + accumulator. - /// The outbound caller (typically a forwarding-proxy plugin) - /// populates the input fields (`bearer_token`, `target_name`, - /// `target_audience`, `required_permissions`, …) and invokes the - /// hook; handlers populate the output fields - /// (`delegated_token`, `delegation_update`, `metadata`) on clones - /// of the running payload. Input fields are private and read - /// through accessors — handlers cannot mutate them even on a - /// clone, so the delegation context is canonical across the chain. - /// - /// **Result** ([`PluginResult`][PluginResult]) - /// — the executor's standard envelope. `modified_payload` - /// carries the updated payload. `continue_processing = false` - /// halts the pipeline (handler decided no credential can be - /// minted — e.g. the inbound token's scopes don't cover the - /// target's required permissions). - /// - /// **Threading.** Sequential-phase semantics already thread - /// handler N's `modified_payload` into handler N+1's input, so - /// the chain's natural behavior is "each handler sees the prior - /// handler's contributions in the running payload." Most - /// deployments will register exactly one TokenDelegate handler - /// (RFC 8693 exchanger, UCAN minter, …), but chaining works for - /// hybrid setups — e.g. a passthrough fallback that fires only - /// when the primary exchanger declined. - /// - /// **Handler signature:** - /// - /// ```rust,ignore - /// impl HookHandler for RfcExchanger { - /// async fn handle( - /// &self, - /// payload: &DelegationPayload, - /// _ext: &Extensions, - /// _ctx: &mut PluginContext, - /// ) -> PluginResult { - /// let minted = self - /// .exchange(payload.bearer_token(), payload.target_audience()) - /// .await?; - /// let mut updated = payload.clone(); - /// updated.delegated_token = Some(minted); - /// PluginResult::modify_payload(updated) - /// } - /// } - /// ``` - /// - /// **Registration:** - /// `manager.register_handler_for_names::(plugin, config, &["token.delegate"])`. - /// `register_handler::` alone registers - /// under the marker's `NAME` ("token") which is the hook family, - /// not the specific hook name — `register_handler_for_names` - /// (or the unified-name path) is the right call. - /// - /// [PluginResult]: crate::hooks::trait_def::PluginResult - TokenDelegateHook, "token.delegate" => { - payload: DelegationPayload, - result: PluginResult, - } -} diff --git a/crates/cpex-core/src/delegation/mod.rs b/crates/cpex-core/src/delegation/mod.rs deleted file mode 100644 index 2d47b186..00000000 --- a/crates/cpex-core/src/delegation/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -// Location: ./crates/cpex-core/src/delegation/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Token-delegation hook family — TokenDelegate. -// -// Mirrors the identity/ module layout: the hook marker + handler -// trait machinery (provided by cpex-core's generic hooks layer) -// plus the hook-specific payload + result types. -// -// Scope: data shapes + host helpers — no executor -// wiring (that's free via `mgr.invoke_named::`), -// no TokenCacheControl trait (that lands in a follow-up with -// the cache infrastructure). - -pub mod hook; -pub mod payload; - -pub use hook::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}; -pub use payload::{ - AttenuationConfig, AuthEnforcedBy, DelegationPayload, DelegationSubject, TargetType, -}; diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs deleted file mode 100644 index d3138a74..00000000 --- a/crates/cpex-core/src/delegation/payload.rs +++ /dev/null @@ -1,1182 +0,0 @@ -// Location: ./crates/cpex-core/src/delegation/payload.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `DelegationPayload` — the unified state struct threaded through the -// TokenDelegate hook chain. Same input/output split pattern as -// `IdentityPayload`: -// -// * **Input** (private — host-supplied, never mutated by handlers) — -// `bearer_token`, `actor_token`, `actor_role`, `subject`, `target_name`, -// `target_type`, `target_audience`, `required_permissions`, -// `trust_domain`, `auth_enforced_by`, -// `route_attenuation`. Set once at the call site that needs to mint -// a downstream credential. Privacy is enforced at the module -// boundary: external code reads through accessors and has no -// setters or mutable field access. -// -// * **Accumulating output** (`pub` fields) — `delegated_token` and -// `delegation_update`. Handlers clone the payload, populate these, -// return the updated payload via `PluginResult::modify_payload`. -// -// # Where this hook fits -// -// IdentityResolve is *inbound* — validates the caller's -// credentials at request entry, populates `security.subject` / -// `security.client` / `security.caller_workload`. TokenDelegate is -// *outbound* — when a plugin (typically a forwarding proxy) needs to -// make a downstream call to a tool or agent, it asks for an -// appropriately-scoped credential for that target. A handler (RFC -// 8693 token exchanger, UCAN minter, passthrough) produces the -// minted token; the framework stashes it in -// `Extensions.raw_credentials.delegated_tokens` for the proxy plugin -// to attach on the upstream request. -// -// # Caching -// -// Not implemented yet. The spec describes a `TokenCacheControl` trait -// that wraps this hook with `get_or_mint(audience, scopes)` -// semantics — outbound callers ask the trait for a token; the trait -// hits the cache first and only dispatches through the hook on cache -// miss. That layer comes later. For now, every -// `mgr.invoke_named::(...)` re-runs the chain. -// -// # Rejection -// -// Same as IdentityResolve: handlers reject via -// `PluginResult::deny(PluginViolation::new(code, reason))`. The -// executor halts the chain; no later handler runs and the request -// fails with the violation surfaced to the host. No `rejected` flag -// on the payload. - -use std::collections::HashMap; -use std::sync::Arc; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; - -use crate::executor::PipelineResult; -use crate::extensions::raw_credentials::{DelegationMode, TokenRole}; -use crate::extensions::{ - DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken, -}; -use crate::impl_plugin_payload; - -/// Which principal a delegation exchange is *for* — the party whose -/// identity the minted credential will speak for. -/// -/// Deliberately a separate type from [`TokenRole`]. `TokenRole` keys -/// `RawCredentialsExtension.inbound_tokens`, so it can only ever name -/// a credential that arrived on the wire. `ThisWorkload` names *this -/// instance's own* identity, which by definition does not arrive on the -/// wire — it has no inbound slot and no `TokenRole`. Collapsing the two -/// would make "which workload?" ambiguous all over again. -#[non_exhaustive] -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DelegationSubject { - /// The end user. The ordinary on-behalf-of exchange. - #[default] - User, - /// The OAuth client / application brokering the request. - Client, - /// The *calling* workload — an agent acting autonomously, with no - /// user in the loop. Exchanges the caller's own JWT-SVID. - CallerWorkload, - /// This CPEX instance's own identity — used when the enforcement - /// point holds the downstream access (the "hold the tool credentials - /// here" deployment) and calls the downstream as itself rather than - /// as the caller. Deployment-agnostic: not a claim that CPEX is a - /// gateway. - /// - /// Has no inbound credential to exchange: proves who it is with its - /// own client credentials or its own SVID. - ThisWorkload, -} - -impl DelegationSubject { - /// Which inbound credential supplies this subject's token, or - /// `None` for [`ThisWorkload`] — nothing the caller sent is being - /// exchanged, so there is no inbound slot to read. - /// - /// [`ThisWorkload`]: DelegationSubject::ThisWorkload - pub fn inbound_role(&self) -> Option { - match self { - DelegationSubject::User => Some(TokenRole::User), - DelegationSubject::Client => Some(TokenRole::Client), - DelegationSubject::CallerWorkload => Some(TokenRole::CallerWorkload), - DelegationSubject::ThisWorkload => None, - } - } - - /// The [`DelegationMode`] this subject produces. Kept in cpex-core - /// (rather than in a handler) so every delegation backend attributes - /// and cache-keys a given subject identically: `user` is the only - /// act-on-behalf-of-another; the other three act as themselves. - pub fn default_mode(&self) -> DelegationMode { - match self { - DelegationSubject::User => DelegationMode::OnBehalfOfUser, - DelegationSubject::Client => DelegationMode::AsClient, - DelegationSubject::CallerWorkload => DelegationMode::AsCallerWorkload, - DelegationSubject::ThisWorkload => DelegationMode::AsThisWorkload, - } - } - - /// Parse the value of a `subject:` step key. Returns `None` for - /// anything unrecognized so callers apply their own policy rather - /// than silently substituting a principal for a typo'd one. - /// - /// `"workload"` is accepted as a legacy spelling of `caller_workload`, - /// and `"gateway"` as a deprecated spelling of `this_workload` (the - /// keyword was renamed because CPEX is not necessarily a gateway). - pub fn from_config_str(s: &str) -> Option { - match s { - "user" => Some(DelegationSubject::User), - "client" => Some(DelegationSubject::Client), - "caller_workload" | "workload" => Some(DelegationSubject::CallerWorkload), - "this_workload" | "gateway" => Some(DelegationSubject::ThisWorkload), - _ => None, - } - } -} - -/// Kind of downstream entity the credential is being minted for. -/// `Custom(String)` is the escape hatch for host-defined entity -/// types beyond the well-known shapes. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TargetType { - /// A tool invocation (MCP tool, function call). - Tool, - /// An agent — another LLM-driven actor. - Agent, - /// A static resource (file, URL, document store entry). - Resource, - /// A service (microservice, internal API). - Service, - /// Operator-defined target kind. - #[serde(untagged)] - Custom(String), -} - -impl Default for TargetType { - fn default() -> Self { - TargetType::Tool - } -} - -/// Who's responsible for enforcing authorization on the downstream -/// call. From the `ObjectSecurityProfile` of the target. Determines -/// whether the gateway brokers credentials (`Caller`), trusts the -/// target to handle auth itself (`Target`), or both layers enforce -/// (`Both`). -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AuthEnforcedBy { - /// Caller (the gateway / our process) enforces — typical for - /// internal services that trust the gateway's authorization - /// decision. - Caller, - /// Target enforces — typical for external services with their - /// own access control. We may still attach credentials but the - /// downstream makes the final allow/deny decision. - Target, - /// Both layers enforce — defense in depth. - Both, -} - -impl Default for AuthEnforcedBy { - fn default() -> Self { - AuthEnforcedBy::Caller - } -} - -/// Scope-attenuation config carried from the route DSL. Lets the -/// route author narrow what the minted credential is allowed to do -/// beyond the broad authorization the inbound credential carried. -/// -/// `resource_template` is a templated URI (e.g. -/// `"hr://employees/{{ args.employee_id }}"`) that the framework -/// renders against request-time arguments before passing into the -/// minted token's scope claim. v0 doesn't include a template -/// renderer — handlers receive the raw template string and render -/// themselves; a framework-side renderer can come later. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AttenuationConfig { - /// Specific capabilities the route author wants granted. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub capabilities: Vec, - - /// URI template for the resource being accessed. Unrendered — - /// handlers substitute `{{ args.* }}` placeholders themselves - /// using request context. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resource_template: Option, - - /// Actions allowed on the resource (read / write / delete / - /// custom verbs). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec, - - /// Token lifetime override in seconds. `None` lets the handler - /// pick its default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ttl_seconds: Option, -} - -/// State threaded through the TokenDelegate hook chain. -/// -/// See the module-level docs for the input/output split. Input -/// fields are private (set once via the constructor + builders, -/// never mutated). Output fields are `pub` (handlers populate on -/// clones and return the updated payload). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DelegationPayload { - /// The caller's current credential — the one a token-exchange - /// handler will swap for a downstream-scoped credential. Cleared - /// on drop via `Zeroizing`. `#[serde(skip)]` — never appears in - /// serialized output. - #[serde(skip)] - bearer_token: Zeroizing, - - /// The RFC 8693 `actor_token` — the credential of the party - /// *acting on behalf of* the subject, typically the caller - /// workload's SPIFFE JWT-SVID. Sourced by the invoker from - /// `RawCredentialsExtension[Workload]`, exactly as `bearer_token` - /// is sourced from `[User]`. Empty when the delegation carries no - /// actor (the common single-token exchange). Cleared on drop via - /// `Zeroizing`; `#[serde(skip)]` — never serialized, same - /// invariant as `bearer_token`. - #[serde(skip)] - actor_token: Zeroizing, - - /// Which principal `actor_token` belongs to. `None` when the - /// exchange carries no actor. Travels with `actor_token` because - /// the bytes alone don't say whose they are, and the cache key - /// needs to know whether a workload took part. - #[serde(default, skip_serializing_if = "Option::is_none")] - actor_role: Option, - - /// Which principal this exchange is *for* — whose identity the - /// minted credential will speak for. - /// - /// Handlers can't tell a user token from a workload JWT-SVID by - /// looking at the bytes, but the distinction decides how the - /// minted credential must be attributed: a `CallerWorkload` - /// subject with no user in the picture speaks for the calling - /// agent (`DelegationMode::AsCallerWorkload`), a `User` subject - /// speaks for the user (`OnBehalfOfUser`), a `Client` subject for - /// the calling client (`AsClient`), and `ThisWorkload` speaks for - /// us (`AsThisWorkload`). Recording it here lets a handler *derive* - /// the attribution rather than guess it. - /// - /// `ThisWorkload` additionally tells a handler there is no inbound - /// credential to exchange — `bearer_token` is empty by design, - /// and the handler authenticates as itself instead. - /// - /// Defaults to `User`, which keeps every existing single-token - /// call site meaning exactly what it meant before. - #[serde(default)] - subject: DelegationSubject, - - /// Name of the tool / agent / resource being called. - target_name: String, - - /// Kind of downstream entity. - #[serde(default)] - target_type: TargetType, - - /// Audience URI for the target, from route config. - #[serde(default, skip_serializing_if = "Option::is_none")] - target_audience: Option, - - /// Required permissions from the target's `ObjectSecurityProfile`. - /// Handlers must produce a credential that grants these (or fail). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - required_permissions: Vec, - - /// Target's trust domain (SPIFFE-style) — useful for handlers - /// that mint workload-identity tokens. - #[serde(default, skip_serializing_if = "Option::is_none")] - trust_domain: Option, - - /// Who's responsible for enforcing authorization. - #[serde(default)] - auth_enforced_by: AuthEnforcedBy, - - /// Scope-attenuation config from the route DSL. - #[serde(default, skip_serializing_if = "Option::is_none")] - route_attenuation: Option, - - /// The minted outbound credential. `None` until a handler - /// produces one. Carries the raw bytes (cleared on drop), the - /// header the proxy plugin should attach it under, the - /// audience it was minted for, the effective scopes, and the - /// expiry timestamp. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegated_token: Option, - - /// Chain update — the new hop to append to the running - /// `DelegationExtension`. Handlers append themselves to the - /// chain so audit / policy can trace who delegated to whom. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation_update: Option, - - /// What kind of principal the minted token represents. - /// Handlers populating `delegated_token` should also set this - /// so `apply_to_extensions` keys the cache correctly: - /// - /// * `OnBehalfOfUser` — token speaks for the original user - /// (RFC 8693 on-behalf-of / actor-token, UCAN delegation). - /// Standard flow; cache key includes the user's subject id. - /// * `AsThisWorkload` — token speaks for this instance itself. - /// User identity is conveyed through separate context. - /// Cache key falls back to this instance's identity. - /// - /// `None` defaults to `OnBehalfOfUser` for backward compatibility - /// with handlers that don't yet populate the field. Long-term, - /// handlers should always set this explicitly. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation_mode: Option, - - /// Resolution timestamp. Audit-useful. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub minted_at: Option>, - - /// Optional metadata produced by the handler (telemetry, - /// diagnostics). Not load-bearing for policy. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub metadata: HashMap, -} - -impl DelegationPayload { - /// Construct a payload with the required input fields populated. - /// The most common entry point — outbound callers (forwarding - /// proxies, etc.) build this once per delegation point. Optional - /// input slots are set via the `.with_*` builders below; output - /// fields start as `None` / empty and accumulate as handlers run. - pub fn new(bearer_token: impl Into, target_name: impl Into) -> Self { - Self { - bearer_token: Zeroizing::new(bearer_token.into()), - actor_token: Zeroizing::new(String::new()), - actor_role: None, - subject: DelegationSubject::default(), - target_name: target_name.into(), - target_type: TargetType::Tool, - target_audience: None, - required_permissions: Vec::new(), - trust_domain: None, - auth_enforced_by: AuthEnforcedBy::Caller, - route_attenuation: None, - delegated_token: None, - delegation_update: None, - delegation_mode: None, - minted_at: None, - metadata: HashMap::new(), - } - } - - /// Attach the RFC 8693 actor — the party acting on behalf of the - /// subject — as a (role, credential) pair. The invoker sets this - /// from the inbound workload SVID - /// (`RawCredentialsExtension[CallerWorkload]`) when a delegation step - /// opts into an actor, mirroring how `bearer_token` is sourced - /// from the User-role token. A delegator forwards the token as - /// `actor_token` only when non-empty. - /// - /// Role and token are set together deliberately: a token whose - /// principal is unknown can't be attributed in the audit trail or - /// partitioned correctly in the delegated-token cache. - pub fn with_actor(mut self, role: TokenRole, actor_token: impl Into) -> Self { - self.actor_token = Zeroizing::new(actor_token.into()); - self.actor_role = Some(role); - self - } - - /// Record which principal this exchange is for. The invoker sets - /// this from the step's `subject:` key, so handlers can attribute - /// the minted token correctly instead of assuming a user is - /// present — and can tell that a `ThisWorkload` subject means "no - /// inbound credential, authenticate as yourself." - pub fn with_subject(mut self, subject: DelegationSubject) -> Self { - self.subject = subject; - self - } - - pub fn with_target_type(mut self, t: TargetType) -> Self { - self.target_type = t; - self - } - - pub fn with_target_audience(mut self, aud: impl Into) -> Self { - self.target_audience = Some(aud.into()); - self - } - - pub fn with_required_permissions(mut self, perms: Vec) -> Self { - self.required_permissions = perms; - self - } - - pub fn with_trust_domain(mut self, td: impl Into) -> Self { - self.trust_domain = Some(td.into()); - self - } - - pub fn with_auth_enforced_by(mut self, who: AuthEnforcedBy) -> Self { - self.auth_enforced_by = who; - self - } - - pub fn with_route_attenuation(mut self, cfg: AttenuationConfig) -> Self { - self.route_attenuation = Some(cfg); - self - } - - /// The caller's bearer token — borrowed, no way to move or - /// replace the underlying `Zeroizing` through this. - pub fn bearer_token(&self) -> &str { - &self.bearer_token - } - - /// The actor token — borrowed. Empty string when no actor was - /// attached (the common single-token exchange). Same borrow-only - /// discipline as `bearer_token`: no way to move or replace the - /// underlying `Zeroizing` through this. - pub fn actor_token(&self) -> &str { - &self.actor_token - } - - /// Which principal this exchange is for. - pub fn subject(&self) -> &DelegationSubject { - &self.subject - } - - /// Which principal the actor token speaks for, or `None` when the - /// exchange carries no actor. - pub fn actor_role(&self) -> Option<&TokenRole> { - self.actor_role.as_ref() - } - - /// Whether the caller's attested workload identity took part in - /// this exchange — either as the subject (a workload acting - /// autonomously) or as the RFC 8693 actor alongside a user. - /// - /// Drives whether the minted token's cache key is partitioned by - /// `caller_workload.spiffe_id`. It has to be, in both cases: the - /// minted credential names the specific workload (as `sub` or as - /// `act`), so a token minted for one agent is not interchangeable - /// with one minted for another. - pub fn involves_workload(&self) -> bool { - self.subject == DelegationSubject::CallerWorkload - || self.actor_role == Some(TokenRole::CallerWorkload) - } - - /// Whether the calling OAuth client took part in this exchange — - /// either as the subject (a client acting as itself) or as the RFC - /// 8693 actor alongside a user. The client analogue of - /// [`involves_workload`]: drives whether the cache key is partitioned - /// by `client_id`, which it must be so two different clients requesting - /// the same audience/scopes can't collide on one entry. - /// - /// [`involves_workload`]: DelegationPayload::involves_workload - pub fn involves_client(&self) -> bool { - self.subject == DelegationSubject::Client || self.actor_role == Some(TokenRole::Client) - } - - pub fn target_name(&self) -> &str { - &self.target_name - } - - pub fn target_type(&self) -> &TargetType { - &self.target_type - } - - pub fn target_audience(&self) -> Option<&str> { - self.target_audience.as_deref() - } - - pub fn required_permissions(&self) -> &[String] { - &self.required_permissions - } - - pub fn trust_domain(&self) -> Option<&str> { - self.trust_domain.as_deref() - } - - pub fn auth_enforced_by(&self) -> AuthEnforcedBy { - self.auth_enforced_by - } - - pub fn route_attenuation(&self) -> Option<&AttenuationConfig> { - self.route_attenuation.as_ref() - } - - /// Layer another payload's *output* fields onto this one's, - /// following "Some replaces None, last write wins per slot." - /// Input fields are not touched — the running payload's input - /// is canonical for the whole chain. - /// - /// Metadata is merged (not replaced) — `other`'s keys overlay - /// `self`'s, matching the "later handler additively contributes - /// telemetry" expectation. - pub fn merge(&mut self, other: DelegationPayload) { - if other.delegated_token.is_some() { - self.delegated_token = other.delegated_token; - } - if other.delegation_update.is_some() { - self.delegation_update = other.delegation_update; - } - if other.delegation_mode.is_some() { - self.delegation_mode = other.delegation_mode; - } - if other.minted_at.is_some() { - self.minted_at = other.minted_at; - } - for (k, v) in other.metadata { - self.metadata.insert(k, v); - } - } - - /// Pull the resolved `DelegationPayload` out of a `PipelineResult` - /// returned by `mgr.invoke_named::(...)`. - /// Returns `None` when the pipeline was denied or when the result's - /// payload wasn't a `DelegationPayload`. Same contract as - /// `IdentityPayload::from_pipeline_result`. - pub fn from_pipeline_result(result: &PipelineResult) -> Option { - result - .modified_payload - .as_ref() - .and_then(|p| p.as_any().downcast_ref::()) - .cloned() - } - - /// Apply this payload's resolved output slots back into an - /// `Extensions` container. Returns a new `Extensions` ready to - /// hand to the outbound proxy plugin that will attach the minted - /// credential and forward. - /// - /// Application rules: - /// - /// - **`raw_credentials.delegated_tokens`** — if the payload - /// carries a `delegated_token`, it's inserted into the map under - /// a `DelegationKey` derived from the input fields (audience, - /// subject not yet plumbed — see "Open work" below). Pre-existing - /// delegated tokens are preserved. - /// - **`delegation`** — `delegation_update` overlays on top of - /// the existing chain (Some replaces None / appends). - /// - /// # Key composition - /// - /// `audience`, `scopes` and `mode` come off the payload. The two - /// principal fields are read out of the request's `Extensions` - /// here rather than asking outbound callers to thread them - /// through: - /// - /// - `subject_id` from `security.subject.id`, empty when no user - /// took part (a workload acting autonomously). - /// - `workload_id` from `security.caller_workload.spiffe_id`, - /// populated only when [`involves_workload`] — i.e. when a - /// workload credential was the subject or the RFC 8693 actor. - /// - /// Both are needed. An empty `subject_id` is not a unique - /// principal: every workload-subject exchange has one, so without - /// `workload_id` two different calling agents requesting the same - /// audience and scopes collide on a single key and get served - /// each other's tokens. Populating `workload_id` only when a - /// workload actually participated keeps ordinary user-only - /// delegations sharing one entry rather than being partitioned - /// per caller for no reason. - /// - /// [`involves_workload`]: DelegationPayload::involves_workload - pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { - if let Some(ref token) = self.delegated_token { - use crate::extensions::raw_credentials::DelegationKey; - - let subject_id = ext - .security - .as_ref() - .and_then(|s| s.subject.as_ref()) - .and_then(|s| s.id.clone()) - .unwrap_or_default(); - - // Which calling agent this token was minted for. Only set - // when a workload actually participated — see the - // "Key composition" note above for why each principal - // has to be in the key. - let workload_id = if self.involves_workload() { - ext.security - .as_ref() - .and_then(|s| s.caller_workload.as_ref()) - .and_then(|w| w.spiffe_id.clone()) - } else { - None - }; - - // Which calling client this token was minted for. Same - // rationale as `workload_id`: without it, two clients hitting - // the same audience/scopes (both with an empty `subject_id`) - // would collide on one entry and be served each other's tokens. - let client_id = if self.involves_client() { - ext.security - .as_ref() - .and_then(|s| s.client.as_ref()) - .map(|c| c.client_id.clone()) - .filter(|id| !id.is_empty()) - } else { - None - }; - - // Default to OnBehalfOfUser when the handler didn't - // populate `delegation_mode`. Backward-compatible with - // earlier handlers; future handlers should - // populate the field explicitly. - let mode = self - .delegation_mode - .clone() - .unwrap_or(DelegationMode::OnBehalfOfUser); - let key = DelegationKey { - subject_id, - workload_id, - client_id, - audience: token.audience.clone(), - scopes: token.scopes.clone(), - mode, - }; - - let mut raw = ext - .raw_credentials - .as_ref() - .map(|arc| (**arc).clone()) - .unwrap_or_else(RawCredentialsExtension::default); - raw.delegated_tokens.insert(key, token.clone()); - ext.raw_credentials = Some(Arc::new(raw)); - } - - if let Some(ref update) = self.delegation_update { - // Replace wholesale for v0. A per-hop append semantics - // would deep-merge the chain, but `DelegationExtension`'s - // append rules live with the type — handlers that want - // to add a hop produce a `DelegationExtension` containing - // the new hop in its chain. - ext.delegation = Some(Arc::new(update.clone())); - } - - ext - } -} - -impl_plugin_payload!(DelegationPayload); - -#[cfg(test)] -mod tests { - use super::*; - use crate::extensions::raw_credentials::RawDelegatedToken; - - #[test] - fn bearer_token_does_not_serialize() { - let p = DelegationPayload::new("eyJ.caller.tok", "get_compensation"); - let json = serde_json::to_string(&p).unwrap(); - assert!( - !json.contains("eyJ.caller.tok"), - "bearer_token leaked into serialized form: {}", - json, - ); - assert!(json.contains("get_compensation")); - } - - #[test] - fn deserialize_yields_empty_bearer_token() { - let json = r#"{"target_name":"get_compensation"}"#; - let p: DelegationPayload = serde_json::from_str(json).unwrap(); - assert_eq!(p.bearer_token(), ""); - assert_eq!(p.target_name(), "get_compensation"); - } - - #[test] - fn actor_token_defaults_empty_and_builder_sets_it() { - // No actor by default — the common single-token exchange. - let p = DelegationPayload::new("caller.tok", "get_compensation"); - assert_eq!(p.actor_token(), ""); - assert_eq!(p.actor_role(), None); - - // Builder attaches the workload SVID (as the invoker does), - // recording whose credential it is at the same time. - let p = p.with_actor(TokenRole::CallerWorkload, "svid.jwt.bytes"); - assert_eq!(p.actor_token(), "svid.jwt.bytes"); - assert_eq!(p.actor_role(), Some(&TokenRole::CallerWorkload)); - // Subject is untouched — the two tokens are independent slots. - assert_eq!(p.bearer_token(), "caller.tok"); - } - - #[test] - fn involves_workload_covers_both_subject_and_actor_positions() { - // Neither position: a plain user delegation. - let user_only = DelegationPayload::new("caller.tok", "t"); - assert!(!user_only.involves_workload()); - - // Subject position (Mode A) — workload acting autonomously. - let mode_a = - DelegationPayload::new("svid", "t").with_subject(DelegationSubject::CallerWorkload); - assert!(mode_a.involves_workload()); - - // Actor position (Mode B) — user subject, workload actor. The - // minted token names the workload in `act`, so it still has to - // partition the cache. - let mode_b = DelegationPayload::new("user.tok", "t") - .with_actor(TokenRole::CallerWorkload, "svid.jwt.bytes"); - assert!(mode_b.involves_workload()); - - // A non-workload actor doesn't trigger it — nothing to key by. - let client_actor = - DelegationPayload::new("user.tok", "t").with_actor(TokenRole::Client, "client.tok"); - assert!(!client_actor.involves_workload()); - } - - #[test] - fn subject_defaults_to_user_and_builder_overrides_it() { - // Default keeps every pre-existing single-token call site - // meaning what it always meant: on-behalf-of a user. - let p = DelegationPayload::new("caller.tok", "get_compensation"); - assert_eq!(p.subject(), &DelegationSubject::User); - - // The calling agent acting autonomously. - let p = p.with_subject(DelegationSubject::CallerWorkload); - assert_eq!(p.subject(), &DelegationSubject::CallerWorkload); - } - - /// `ThisWorkload` is the one subject with no inbound credential — it - /// proves who it is by being itself rather than by exchanging - /// something the caller sent. Handlers key the "an empty bearer - /// token is expected here" decision off exactly this. - #[test] - fn only_this_workload_has_no_inbound_role() { - assert_eq!( - DelegationSubject::User.inbound_role(), - Some(TokenRole::User), - ); - assert_eq!( - DelegationSubject::Client.inbound_role(), - Some(TokenRole::Client), - ); - assert_eq!( - DelegationSubject::CallerWorkload.inbound_role(), - Some(TokenRole::CallerWorkload), - ); - assert_eq!(DelegationSubject::ThisWorkload.inbound_role(), None); - } - - #[test] - fn subject_parses_from_config_including_legacy_spellings() { - assert_eq!( - DelegationSubject::from_config_str("caller_workload"), - Some(DelegationSubject::CallerWorkload), - ); - // Configs written before the renames keep working. - assert_eq!( - DelegationSubject::from_config_str("workload"), - Some(DelegationSubject::CallerWorkload), - ); - assert_eq!( - DelegationSubject::from_config_str("this_workload"), - Some(DelegationSubject::ThisWorkload), - ); - // `gateway` is the deprecated spelling of `this_workload`. - assert_eq!( - DelegationSubject::from_config_str("gateway"), - Some(DelegationSubject::ThisWorkload), - ); - // A typo resolves to None so the caller applies its own - // default rather than silently picking a principal. - assert_eq!(DelegationSubject::from_config_str("gatewy"), None); - } - - #[test] - fn actor_token_does_not_serialize() { - let p = DelegationPayload::new("caller.tok", "get_compensation") - .with_actor(TokenRole::CallerWorkload, "eyJ.workload.svid"); - let json = serde_json::to_string(&p).unwrap(); - assert!( - !json.contains("eyJ.workload.svid"), - "actor_token leaked into serialized form: {}", - json, - ); - } - - #[test] - fn input_builders_chain() { - let p = DelegationPayload::new("tok", "get_compensation") - .with_target_type(TargetType::Tool) - .with_target_audience("https://hr.example.com") - .with_required_permissions(vec!["read:compensation".into()]) - .with_trust_domain("hr.example.com") - .with_auth_enforced_by(AuthEnforcedBy::Target) - .with_route_attenuation(AttenuationConfig { - capabilities: vec!["read:compensation".into()], - resource_template: Some("hr://employees/{{ args.employee_id }}".into()), - actions: vec!["read".into()], - ttl_seconds: Some(60), - }); - assert_eq!(p.bearer_token(), "tok"); - assert_eq!(p.target_name(), "get_compensation"); - assert_eq!(p.target_audience(), Some("https://hr.example.com")); - assert_eq!(p.required_permissions(), &["read:compensation".to_string()]); - assert_eq!(p.trust_domain(), Some("hr.example.com")); - assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Target); - let att = p.route_attenuation().unwrap(); - assert_eq!(att.ttl_seconds, Some(60)); - assert_eq!(att.actions, vec!["read"]); - } - - #[test] - fn target_type_custom_round_trips() { - let t = TargetType::Custom("workflow".into()); - let json = serde_json::to_string(&t).unwrap(); - let back: TargetType = serde_json::from_str(&json).unwrap(); - assert_eq!(t, back); - } - - #[test] - fn handler_can_populate_output_on_clone() { - // Typical handler pattern: clone running payload, set - // delegated_token + delegation_update, return. - let original = DelegationPayload::new("caller-tok", "downstream-tool"); - let mut updated = original.clone(); - updated.delegated_token = Some(RawDelegatedToken::new( - "minted-bytes", - "Authorization", - "https://api.example.com", - vec!["read".into()], - Utc::now(), - )); - // Input survives the clone. - assert_eq!(updated.bearer_token(), "caller-tok"); - assert_eq!(updated.target_name(), "downstream-tool"); - // Output populated. - assert!(updated.delegated_token.is_some()); - // Original untouched. - assert!(original.delegated_token.is_none()); - } - - #[test] - fn merge_overlays_outputs() { - let mut base = DelegationPayload::new("tok", "tool"); - base.metadata.insert("attempt".into(), serde_json::json!(1)); - let mut overlay = DelegationPayload::new("", ""); - overlay.delegated_token = Some(RawDelegatedToken::new( - "x", - "Authorization", - "aud", - vec![], - Utc::now(), - )); - overlay - .metadata - .insert("latency_ms".into(), serde_json::json!(42)); - base.merge(overlay); - assert!(base.delegated_token.is_some()); - // Metadata merged additively — both keys present. - assert!(base.metadata.contains_key("attempt")); - assert!(base.metadata.contains_key("latency_ms")); - } - - #[test] - fn apply_to_extensions_writes_delegated_token_keyed_by_audience() { - use crate::extensions::raw_credentials::DelegationMode; - use crate::extensions::SubjectExtension; - - let mut p = DelegationPayload::new("tok", "get_compensation"); - p.delegated_token = Some(RawDelegatedToken::new( - "minted-jwt", - "Authorization", - "https://hr.example.com", - vec!["read:compensation".into()], - Utc::now() + chrono::Duration::seconds(300), - )); - - // Pre-existing subject in extensions — DelegationKey.subject_id - // should pull from there. - let initial_ext = Extensions { - security: Some(Arc::new(crate::extensions::SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }), - ..Default::default() - })), - ..Default::default() - }; - - let updated = p.apply_to_extensions(initial_ext); - let raw = updated.raw_credentials.as_ref().unwrap(); - assert_eq!(raw.delegated_tokens.len(), 1); - - // Look up by the synthesized key. - let expected_key = crate::extensions::raw_credentials::DelegationKey { - subject_id: "alice".into(), - // No workload in this exchange, so the key isn't - // partitioned by one. - workload_id: None, - client_id: None, - audience: "https://hr.example.com".into(), - scopes: vec!["read:compensation".into()], - mode: DelegationMode::OnBehalfOfUser, - }; - assert!(raw.delegated_tokens.contains_key(&expected_key)); - } - - /// Two different calling agents run the same workload-subject - /// exchange against the same audience and scopes, sharing one - /// `delegated_tokens` map. Neither has a user, so both keys carry an - /// empty `subject_id`; only `workload_id` keeps them apart. - /// - /// What this proves: the two exchanges build **distinct keys** and - /// insert as **two** separate entries rather than collapsing into one. - /// It does *not* exercise cross-request serving — `delegated_tokens` - /// is per-request today and nothing reads it across requests. The - /// distinct-key property is the precondition that keeps a future - /// cross-request lookup path from serving one agent another's token. - #[test] - fn two_agents_do_not_share_one_cache_entry() { - use crate::extensions::security::WorkloadIdentity; - - /// A Mode A payload: workload subject, no user. - fn workload_exchange(minted: &str) -> DelegationPayload { - let mut p = DelegationPayload::new("svid-bytes", "get_compensation") - .with_subject(DelegationSubject::CallerWorkload); - p.delegated_token = Some(RawDelegatedToken::new( - minted, - "Authorization", - "https://hr.example.com", - vec!["read:compensation".into()], - Utc::now() + chrono::Duration::seconds(300), - )); - p.delegation_mode = Some(DelegationMode::AsCallerWorkload); - p - } - - /// Extensions whose attested caller is `spiffe_id`, carrying - /// over any already-cached tokens so the two exchanges share - /// one map. - fn ext_for(spiffe_id: &str, carry: Option>) -> Extensions { - Extensions { - security: Some(Arc::new(crate::extensions::SecurityExtension { - caller_workload: Some(WorkloadIdentity { - spiffe_id: Some(spiffe_id.into()), - ..Default::default() - }), - ..Default::default() - })), - raw_credentials: carry, - ..Default::default() - } - } - - // Agent 1 mints, then agent 2 mints against the same map. - let after_payroll = workload_exchange("payroll-token") - .apply_to_extensions(ext_for("spiffe://corp/payroll", None)); - let carried = after_payroll.raw_credentials.clone(); - let after_both = workload_exchange("recruiting-token") - .apply_to_extensions(ext_for("spiffe://corp/recruiting", carried)); - - let raw = after_both.raw_credentials.as_ref().unwrap(); - assert_eq!( - raw.delegated_tokens.len(), - 2, - "each calling agent must get its own cache entry; keys: {:?}", - raw.delegated_tokens.keys().collect::>(), - ); - - // And each entry holds that agent's own token — the point of - // the exercise. - let lookup = |spiffe: &str| { - raw.delegated_tokens - .get(&crate::extensions::raw_credentials::DelegationKey { - subject_id: String::new(), - workload_id: Some(spiffe.into()), - client_id: None, - audience: "https://hr.example.com".into(), - scopes: vec!["read:compensation".into()], - mode: DelegationMode::AsCallerWorkload, - }) - .map(|t| (*t.token).clone()) - }; - assert_eq!( - lookup("spiffe://corp/payroll").as_deref(), - Some("payroll-token"), - ); - assert_eq!( - lookup("spiffe://corp/recruiting").as_deref(), - Some("recruiting-token"), - ); - } - - /// The client analogue of `two_agents_do_not_share_one_cache_entry`: - /// two different OAuth clients run the same client-subject exchange - /// for the same audience/scopes, sharing one `delegated_tokens` map. - /// Neither has a user, so both keys carry an empty `subject_id`; only - /// `client_id` keeps them apart — and the token is attributed to the - /// client (`AsClient`), not filed under a user that never took part. - #[test] - fn two_clients_do_not_share_one_cache_entry() { - use crate::extensions::ClientExtension; - - fn client_exchange(minted: &str) -> DelegationPayload { - let mut p = DelegationPayload::new("client-tok", "get_compensation") - .with_subject(DelegationSubject::Client); - p.delegated_token = Some(RawDelegatedToken::new( - minted, - "Authorization", - "https://hr.example.com", - vec!["read:compensation".into()], - Utc::now() + chrono::Duration::seconds(300), - )); - p.delegation_mode = Some(DelegationSubject::Client.default_mode()); - p - } - - fn ext_for(client_id: &str, carry: Option>) -> Extensions { - Extensions { - security: Some(Arc::new(crate::extensions::SecurityExtension { - client: Some(ClientExtension { - client_id: client_id.into(), - ..Default::default() - }), - ..Default::default() - })), - raw_credentials: carry, - ..Default::default() - } - } - - let after_a = client_exchange("token-a").apply_to_extensions(ext_for("client-a", None)); - let carried = after_a.raw_credentials.clone(); - let after_both = - client_exchange("token-b").apply_to_extensions(ext_for("client-b", carried)); - - let raw = after_both.raw_credentials.as_ref().unwrap(); - assert_eq!( - raw.delegated_tokens.len(), - 2, - "each client must get its own cache entry; keys: {:?}", - raw.delegated_tokens.keys().collect::>(), - ); - - // Look up by the client-attributed key: AsClient + client_id, with - // an empty subject_id (no user). If the fix regressed — subject_id - // used instead of client_id, or mode OnBehalfOfUser — this misses. - let lookup = |client_id: &str| { - raw.delegated_tokens - .get( - &crate::extensions::raw_credentials::DelegationKey::new( - DelegationMode::AsClient, - "https://hr.example.com", - vec!["read:compensation".into()], - ) - .with_client_id(Some(client_id.into())), - ) - .map(|t| (*t.token).clone()) - }; - assert_eq!(lookup("client-a").as_deref(), Some("token-a")); - assert_eq!(lookup("client-b").as_deref(), Some("token-b")); - } - - #[test] - fn apply_to_extensions_respects_explicit_delegation_mode() { - // Handler that mints an AsThisWorkload-mode token (this instance - // as principal). The key in `delegated_tokens` should carry - // AsThisWorkload, not the default OnBehalfOfUser. - let mut p = DelegationPayload::new("tok", "tool"); - p.delegated_token = Some(RawDelegatedToken::new( - "this-workload-token", - "Authorization", - "https://downstream.example.com", - vec!["service:call".into()], - Utc::now(), - )); - p.delegation_mode = - Some(crate::extensions::raw_credentials::DelegationMode::AsThisWorkload); - - let updated = p.apply_to_extensions(Extensions::default()); - let raw = updated.raw_credentials.as_ref().unwrap(); - let key = raw.delegated_tokens.keys().next().unwrap(); - assert!(matches!( - key.mode, - crate::extensions::raw_credentials::DelegationMode::AsThisWorkload - )); - } - - #[test] - fn apply_to_extensions_defaults_delegation_mode_when_unset() { - // Handler that didn't populate delegation_mode — apply should - // use OnBehalfOfUser as the safe default. - let mut p = DelegationPayload::new("tok", "tool"); - p.delegated_token = Some(RawDelegatedToken::new( - "user-token", - "Authorization", - "https://aud.example.com", - vec!["read".into()], - Utc::now(), - )); - // delegation_mode left None. - let updated = p.apply_to_extensions(Extensions::default()); - let raw = updated.raw_credentials.as_ref().unwrap(); - let key = raw.delegated_tokens.keys().next().unwrap(); - assert!(matches!( - key.mode, - crate::extensions::raw_credentials::DelegationMode::OnBehalfOfUser - )); - } - - #[test] - fn merge_threads_delegation_mode_through_chain() { - // Handler A leaves delegation_mode unset; handler B sets it. - // After merge, the accumulator should carry handler B's mode. - let mut base = DelegationPayload::new("tok", "tool"); - // base.delegation_mode = None - let mut overlay = DelegationPayload::new("", ""); - overlay.delegation_mode = - Some(crate::extensions::raw_credentials::DelegationMode::AsThisWorkload); - base.merge(overlay); - assert!(matches!( - base.delegation_mode, - Some(crate::extensions::raw_credentials::DelegationMode::AsThisWorkload) - )); - } - - #[test] - fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() { - // this_workload-as-principal flow — no Subject extension present. - // The DelegationKey falls back to empty subject_id rather - // than panicking; flagged via tracing in production but - // not fatal here. - let mut p = DelegationPayload::new("tok", "tool"); - p.delegated_token = Some(RawDelegatedToken::new( - "minted", - "Authorization", - "aud", - vec![], - Utc::now(), - )); - let updated = p.apply_to_extensions(Extensions::default()); - let raw = updated.raw_credentials.as_ref().unwrap(); - let key = raw.delegated_tokens.keys().next().unwrap(); - assert_eq!(key.subject_id, ""); - } - - #[test] - fn auth_enforced_by_defaults_to_caller() { - let p = DelegationPayload::new("tok", "tool"); - assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Caller); - } - - #[test] - fn target_type_defaults_to_tool() { - let p = DelegationPayload::new("tok", "tool"); - assert_eq!(p.target_type(), &TargetType::Tool); - } -} diff --git a/crates/cpex-core/src/elicitation/hook.rs b/crates/cpex-core/src/elicitation/hook.rs deleted file mode 100644 index 0ad9d94c..00000000 --- a/crates/cpex-core/src/elicitation/hook.rs +++ /dev/null @@ -1,78 +0,0 @@ -// Location: ./crates/cpex-core/src/elicitation/hook.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `ElicitationHook` — the `HookTypeDef` marker for the Elicitation hook -// family. Plugins implement `HookHandler`; the apl-cpex -// bridge dispatches into it to drive a human-in-the-loop step (approval, -// confirmation, step-up, …). -// -// Single hook name: `"elicit"`. The three touch-points (dispatch / -// check / validate) are NOT separate hook names — they share one -// payload shape and are distinguished by `ElicitationPayload::operation`. -// This keeps registration and the plan trivial: one plugin, one entry, -// resolved `name → entry` exactly like delegation's `token.delegate`. - -use crate::hooks::trait_def::PluginResult; - -use super::payload::ElicitationPayload; - -/// Hook name for Elicitation handlers. -pub const HOOK_ELICIT: &str = "elicit"; - -crate::define_hook! { - /// Elicitation hook — drives a human-in-the-loop step through a - /// channel plugin (Keycloak CIBA, Slack, in-band, …). - /// - /// **Payload** ([`ElicitationPayload`]) — unified input + accumulator. - /// The apl-cpex bridge sets the input fields (`operation`, - /// `elicitation_id`, `kind`, `from`, `purpose`, `scope`, `timeout`, - /// `channel`) and invokes the hook; the handler populates the output - /// fields (`id`, `status`, `outcome`, `approver`, `intent_id`, - /// `expires_at`, `valid`, `reason`) on a clone of the running payload - /// and returns it via [`PluginResult::modify_payload`]. Input fields - /// are private and read through accessors. - /// - /// **Result** ([`PluginResult`][PluginResult]) — - /// the executor's standard envelope. `modified_payload` carries the - /// updated payload. `continue_processing = false` halts (the handler - /// could not service the operation — e.g. unknown channel error); the - /// bridge maps that to an `ElicitationError`. - /// - /// **Three operations, one hook.** [`ElicitationPayload::operation`] - /// tells the handler whether this is a `Dispatch`, `Check`, or - /// `Validate` call. A handler typically `match`es on it. The three - /// short, synchronous calls span the (possibly hours-long) human gap, - /// which is owned by the channel — never by a handler call. - /// - /// **Handler signature:** - /// - /// ```rust,ignore - /// impl HookHandler for CibaApprover { - /// async fn handle( - /// &self, - /// payload: &ElicitationPayload, - /// _ext: &Extensions, - /// _ctx: &mut PluginContext, - /// ) -> PluginResult { - /// let mut out = payload.clone(); - /// match payload.operation() { - /// ElicitationOp::Dispatch => { /* register intent + CIBA backchannel */ } - /// ElicitationOp::Check => { /* poll Keycloak token endpoint */ } - /// ElicitationOp::Validate => { /* verify token + intent binding */ } - /// } - /// PluginResult::modify_payload(out) - /// } - /// } - /// ``` - /// - /// **Registration:** - /// `manager.register_handler_for_names::(plugin, config, &["elicit"])`. - /// - /// [PluginResult]: crate::hooks::trait_def::PluginResult - ElicitationHook, "elicit" => { - payload: ElicitationPayload, - result: PluginResult, - } -} diff --git a/crates/cpex-core/src/elicitation/mod.rs b/crates/cpex-core/src/elicitation/mod.rs deleted file mode 100644 index 340f9417..00000000 --- a/crates/cpex-core/src/elicitation/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Location: ./crates/cpex-core/src/elicitation/mod.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Elicitation hook family — human-in-the-loop dispatch (approval, -// confirmation, step-up, attestation, …). -// -// Mirrors the delegation/ module layout: the hook marker (via the -// generic hooks layer) plus the hook-specific payload + enums. No -// executor wiring needed — dispatch is free via -// `mgr.invoke_entries::`. The apl-cpex bridge fills the -// payload and maps the result back to apl-core's `ElicitationInvoker` -// return types. - -pub mod hook; -pub mod payload; - -pub use hook::{ElicitationHook, HOOK_ELICIT}; -pub use payload::{ - ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, ElicitationStatusKind, -}; diff --git a/crates/cpex-core/src/elicitation/payload.rs b/crates/cpex-core/src/elicitation/payload.rs deleted file mode 100644 index 5c7f00d3..00000000 --- a/crates/cpex-core/src/elicitation/payload.rs +++ /dev/null @@ -1,335 +0,0 @@ -// Location: ./crates/cpex-core/src/elicitation/payload.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `ElicitationPayload` — the unified state struct threaded through the -// Elicitation hook chain. Same input/accumulator split as -// `DelegationPayload`: -// -// * **Input** (private — bridge-supplied, never mutated by handlers) — -// `operation`, `elicitation_id`, `kind`, `from`, `purpose`, `scope`, -// `timeout`, `channel`. Set once by the apl-cpex bridge before -// `invoke_entries::`. Read through accessors; no -// setters or mutable field access at the module boundary. -// -// * **Accumulating output** (`pub` fields) — `id`, `status`, `outcome`, -// `approver`, `intent_id`, `expires_at`, `valid`, `reason`, -// `metadata`. Handlers clone the payload, populate the slots relevant -// to the `operation`, and return it via `PluginResult::modify_payload`. -// -// # Three operations, one payload -// -// Unlike delegation (one mint per call), an elicitation has three -// touch-points across its lifetime — dispatch / check / validate. They -// share this one payload shape and differ only in which `operation` the -// bridge sets and which output slots the handler fills: -// -// * `Dispatch` → handler registers the intent / opens the channel -// backchannel, fills `id` / `approver` / `intent_id` / -// `expires_at` / `status = Pending`. -// * `Check` → handler reads current state, fills `status` (and -// `outcome` when resolved). -// * `Validate` → handler verifies the response is genuine, fills -// `valid` / `approver` / `intent_id` / `reason`. -// -// The hours-long human gap lives in the channel (e.g. Keycloak CIBA), -// never in a handler call — each operation is short and synchronous. -// -// # Decoupling from apl-core -// -// cpex-core does not depend on apl-core, so this module defines its own -// `ElicitationOp` / `ElicitationStatusKind` / `ElicitationOutcomeKind` -// rather than reusing apl-core's `ElicitKind` / `ElicitationStatus`. The -// apl-cpex bridge maps between the two. `kind` is a free string -// (`"approval"`, `"confirm"`, …) — the per-kind *validation contract* is -// the apl-core runtime's job, so the handler only needs it informationally. -// -// # Rejection -// -// Same as delegation: handlers reject via -// `PluginResult::deny(PluginViolation::new(code, reason))`. The executor -// halts the chain and the bridge maps that to an `ElicitationError`. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::executor::PipelineResult; -use crate::impl_plugin_payload; - -/// Which of the three elicitation touch-points this invocation is. The -/// handler dispatches on it to decide what to do and which output slots -/// to fill. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ElicitationOp { - /// First arrival — register the intent / open the backchannel. - Dispatch, - /// Retry — read current status without blocking. - Check, - /// Resolved — verify the response is genuine. - Validate, -} - -/// Current state of a dispatched elicitation, reported by a `Check` -/// handler. Mirrors apl-core's `ElicitationStatus` shape without the -/// dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ElicitationStatusKind { - /// The human has not responded yet. - Pending, - /// The human responded — see `outcome` for approved/denied. - Resolved, - /// The elicitation timed out before a response. - Expired, -} - -/// The human's decision once an elicitation resolves. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ElicitationOutcomeKind { - Approved, - Denied, -} - -/// State threaded through the Elicitation hook chain. See the -/// module-level docs for the input/accumulator split. Input fields are -/// private (set once via the constructor + builders, never mutated). -/// Output fields are `pub` (handlers populate on clones and return the -/// updated payload). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ElicitationPayload { - /// Which touch-point this is. - operation: ElicitationOp, - - /// Correlation id from a prior `Dispatch`. `None` on dispatch (the - /// handler mints it); `Some` on check / validate. - elicitation_id: Option, - - /// Elicitation kind (`"approval"`, `"confirm"`, `"step_up"`, …) — - /// informational for the handler; the validation contract is enforced - /// by the apl-core runtime, not here. - kind: String, - - /// Resolved approver identity (the apl-core `from` attr already - /// resolved against the request bag by the bridge). For CIBA this is - /// the `login_hint`. - from: String, - - /// Human-readable description of what's being asked — CIBA - /// `binding_message`, audited verbatim. `None` for kinds that carry - /// their prompt elsewhere. - purpose: Option, - - /// The APL scope expression string, passed through for the handler to - /// record alongside the registered intent (the runtime evaluates it, - /// not the handler). `None` for kinds without arg binding. - scope: Option, - - /// Validity window (e.g. `"24h"`) — CIBA `requested_expiry`. `None` - /// defers to the handler's configured default. - timeout: Option, - - /// Optional channel label (`"ciba"` / `"slack"` / …) for the handler's - /// own logging/telemetry. Not a routing key (the plugin was already - /// selected by name). - channel: Option, - - /// Correlation id minted on `Dispatch`. The agent echoes it on retry. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Current status — set by `Check` (and `Dispatch`, which leaves it - /// `Pending`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// Approved / denied — set by `Check` once `status` is `Resolved`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub outcome: Option, - - /// Resolved approver identity — set by `Dispatch` (when known) and by - /// `Validate` (the consenting party, cross-checked against `from`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approver: Option, - - /// Registered intent id (lodging-intent binding) — set by `Dispatch` - /// and echoed by `Validate` for audit reconciliation. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub intent_id: Option, - - /// Expiry timestamp (RFC 3339) — set by `Dispatch` when the channel - /// reports one. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - - /// Genuineness verdict — set by `Validate`. `true` when the signed - /// response validates, its intent binding matches, and the responder - /// is the approver. (The runtime layers the scope-over-args check on - /// top before honoring an approval.) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub valid: Option, - - /// Why a `Check`/`Validate` reported the state it did — failure reason - /// when `valid` is `false`, or diagnostic context. `None` on success. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - - /// Optional handler metadata (telemetry, diagnostics). Not load-bearing. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub metadata: HashMap, -} - -impl ElicitationPayload { - /// Construct a payload for the given operation + kind + resolved - /// approver. Optional input slots are set via the `.with_*` builders; - /// output fields start empty and accumulate as the handler runs. - pub fn new(operation: ElicitationOp, kind: impl Into, from: impl Into) -> Self { - Self { - operation, - elicitation_id: None, - kind: kind.into(), - from: from.into(), - purpose: None, - scope: None, - timeout: None, - channel: None, - id: None, - status: None, - outcome: None, - approver: None, - intent_id: None, - expires_at: None, - valid: None, - reason: None, - metadata: HashMap::new(), - } - } - - /// Set the correlation id (check / validate operations). - pub fn with_elicitation_id(mut self, id: impl Into) -> Self { - self.elicitation_id = Some(id.into()); - self - } - - pub fn with_purpose(mut self, purpose: impl Into) -> Self { - self.purpose = Some(purpose.into()); - self - } - - pub fn with_scope(mut self, scope: impl Into) -> Self { - self.scope = Some(scope.into()); - self - } - - pub fn with_timeout(mut self, timeout: impl Into) -> Self { - self.timeout = Some(timeout.into()); - self - } - - pub fn with_channel(mut self, channel: impl Into) -> Self { - self.channel = Some(channel.into()); - self - } - - pub fn operation(&self) -> ElicitationOp { - self.operation - } - - pub fn elicitation_id(&self) -> Option<&str> { - self.elicitation_id.as_deref() - } - - pub fn kind(&self) -> &str { - &self.kind - } - - pub fn from(&self) -> &str { - &self.from - } - - pub fn purpose(&self) -> Option<&str> { - self.purpose.as_deref() - } - - pub fn scope(&self) -> Option<&str> { - self.scope.as_deref() - } - - pub fn timeout(&self) -> Option<&str> { - self.timeout.as_deref() - } - - pub fn channel(&self) -> Option<&str> { - self.channel.as_deref() - } - - /// Pull the resolved `ElicitationPayload` out of a `PipelineResult` - /// returned by `mgr.invoke_entries::(...)`. Returns - /// `None` when the pipeline was denied or the result's payload wasn't - /// an `ElicitationPayload`. Same contract as - /// `DelegationPayload::from_pipeline_result`. - pub fn from_pipeline_result(result: &PipelineResult) -> Option { - result - .modified_payload - .as_ref() - .and_then(|p| p.as_any().downcast_ref::()) - .cloned() - } -} - -impl_plugin_payload!(ElicitationPayload); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_sets_input_and_leaves_output_empty() { - let p = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@example.com") - .with_purpose("Approve $25,000 raise") - .with_scope("args.amount <= 25000") - .with_timeout("24h") - .with_channel("ciba"); - - assert_eq!(p.operation(), ElicitationOp::Dispatch); - assert_eq!(p.kind(), "approval"); - assert_eq!(p.from(), "alice@example.com"); - assert_eq!(p.purpose(), Some("Approve $25,000 raise")); - assert_eq!(p.scope(), Some("args.amount <= 25000")); - assert_eq!(p.timeout(), Some("24h")); - assert_eq!(p.channel(), Some("ciba")); - assert!(p.elicitation_id().is_none()); - // Output slots start empty. - assert!(p.id.is_none()); - assert!(p.status.is_none()); - assert!(p.valid.is_none()); - } - - #[test] - fn with_elicitation_id_sets_correlation() { - let p = ElicitationPayload::new(ElicitationOp::Check, "approval", "alice@example.com") - .with_elicitation_id("elic-123"); - assert_eq!(p.elicitation_id(), Some("elic-123")); - assert_eq!(p.operation(), ElicitationOp::Check); - } - - #[test] - fn payload_roundtrips_through_serde() { - // The executor clones payloads across handler boundaries via serde - // in some paths — confirm a populated output survives a round-trip. - let mut p = - ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@example.com"); - p.id = Some("elic-1".into()); - p.status = Some(ElicitationStatusKind::Pending); - p.intent_id = Some("intent-9".into()); - - let json = serde_json::to_string(&p).unwrap(); - let back: ElicitationPayload = serde_json::from_str(&json).unwrap(); - assert_eq!(back.id.as_deref(), Some("elic-1")); - assert_eq!(back.status, Some(ElicitationStatusKind::Pending)); - assert_eq!(back.intent_id.as_deref(), Some("intent-9")); - } -} diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs deleted file mode 100644 index fca9b51e..00000000 --- a/crates/cpex-core/src/error.rs +++ /dev/null @@ -1,263 +0,0 @@ -// Location: ./crates/cpex-core/src/error.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Error types for the CPEX plugin framework. -// -// Provides structured error types for plugin execution failures, -// policy violations, timeouts, and configuration errors. Mirrors -// the Python framework's PluginError, PluginViolation, and -// PluginViolationError types. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -/// Top-level error type for the CPEX framework. -/// -/// Covers plugin execution failures, policy violations, timeouts, -/// and configuration issues. Each variant carries enough context -/// for the caller to log, report, or recover. -/// -/// Mirrors the Python framework's `PluginErrorModel` with: -/// - `code` — business-logic error code (e.g., `"rate_limit_exceeded"`) -/// - `details` — structured diagnostic data for logging -/// - `proto_error_code` — protocol-level error code for the host to -/// map back to the wire format (MCP JSON-RPC, HTTP status, etc.) -#[derive(Debug, Error)] -pub enum PluginError { - /// A plugin raised an execution error. - #[error("plugin '{plugin_name}' failed: {message}")] - Execution { - plugin_name: String, - message: String, - /// Business-logic error code (e.g., `"invalid_token"`). - #[source] - source: Option>, - /// Business-logic error code set by the plugin. - code: Option, - /// Structured diagnostic data for logging or debugging. - details: HashMap, - /// Protocol-level error code for the host to map to the wire - /// format. MCP: JSON-RPC codes (e.g., -32603). HTTP: status - /// codes. The host interprets this; CPEX just carries it. - proto_error_code: Option, - }, - - /// A plugin exceeded its execution timeout. - #[error("plugin '{plugin_name}' timed out after {timeout_ms}ms")] - Timeout { - plugin_name: String, - timeout_ms: u64, - /// Protocol-level error code for the host. - proto_error_code: Option, - }, - - /// A plugin returned a policy violation (deny). - #[error("plugin '{plugin_name}' denied: {}", violation.reason)] - Violation { - plugin_name: String, - violation: PluginViolation, - }, - - /// Configuration parsing or validation failed. - #[error("configuration error: {message}")] - Config { message: String }, - - /// A hook type was not found in the registry. - #[error("unknown hook type: {hook_type}")] - UnknownHook { hook_type: String }, -} - -impl PluginError { - /// Box this error for use in `Result>`. - /// - /// Public APIs return `Result>` rather than - /// `Result>` because the enum is large (~184 bytes - /// — `details: HashMap` and the `source: Box` push it - /// well past clippy's `result_large_err` threshold). Boxing keeps - /// `Result` pointer-sized on the success path; the - /// allocation only happens on the error path. - /// - /// `.boxed()` is sugar for `Box::new(...)` that reads better at - /// construction sites: `PluginError::Config { ... }.boxed()`. - /// `?` already calls `From::from`, and `From for Box` is - /// built into std, so existing `?` chains keep working. - pub fn boxed(self) -> Box { - Box::new(self) - } -} - -/// A `Clone`-able, serialization-friendly snapshot of a `PluginError`. -/// -/// Used in `PipelineResult.errors` to surface execution failures from -/// `on_error: ignore` / `on_error: disable` plugins to the caller — -/// previously those errors were only logged via `tracing::warn!` and -/// were invisible to programmatic consumers (agents, dashboards, -/// retry logic). -/// -/// `PluginError` itself can't be `Clone` because of its -/// `Box` source field, and that -/// field doesn't survive serialization anyway. `PluginErrorRecord` -/// flattens the five enum variants into a single shape — the -/// `From<&PluginError>` impl handles the variant-to-fields mapping. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginErrorRecord { - pub plugin_name: String, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub code: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub details: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub proto_error_code: Option, -} - -/// Forward `&Box` to the `&PluginError` impl. -/// -/// Public APIs return `Result>` (see -/// `PluginError::boxed`), which means error-handling code in the -/// pipeline (e.g., `Ok(Err(e))` inside `executor::run_*_phase`) holds -/// `e: Box`. This blanket forward keeps existing -/// `(&e).into()` call sites working without forcing every caller to -/// write `(&*e).into()` after the boxing migration. -impl From<&Box> for PluginErrorRecord { - fn from(e: &Box) -> Self { - PluginErrorRecord::from(e.as_ref()) - } -} - -impl From<&PluginError> for PluginErrorRecord { - fn from(e: &PluginError) -> Self { - match e { - PluginError::Execution { - plugin_name, - message, - code, - details, - proto_error_code, - .. - } => Self { - plugin_name: plugin_name.clone(), - message: message.clone(), - code: code.clone(), - details: details.clone(), - proto_error_code: *proto_error_code, - }, - PluginError::Timeout { - plugin_name, - timeout_ms, - proto_error_code, - } => Self { - plugin_name: plugin_name.clone(), - message: format!("plugin timed out after {}ms", timeout_ms), - code: Some("timeout".into()), - details: HashMap::new(), - proto_error_code: *proto_error_code, - }, - PluginError::Violation { - plugin_name, - violation, - } => Self { - plugin_name: plugin_name.clone(), - message: format!("plugin denied: {}", violation.reason), - code: Some(violation.code.clone()), - details: violation.details.clone(), - proto_error_code: violation.proto_error_code, - }, - PluginError::Config { message } => Self { - plugin_name: String::new(), - message: message.clone(), - code: Some("config".into()), - details: HashMap::new(), - proto_error_code: None, - }, - PluginError::UnknownHook { hook_type } => Self { - plugin_name: String::new(), - message: format!("unknown hook type: {}", hook_type), - code: Some("unknown_hook".into()), - details: HashMap::new(), - proto_error_code: None, - }, - } - } -} - -/// Structured policy violation returned by a plugin that denies execution. -/// -/// Carries a machine-readable code, human-readable reason, and optional -/// diagnostic details. Corresponds to the Python `PluginViolation` type. -/// -/// # Examples -/// -/// ``` -/// use cpex_core::error::PluginViolation; -/// -/// let v = PluginViolation::new("missing_permission", "User lacks pii_access"); -/// assert_eq!(v.code, "missing_permission"); -/// assert_eq!(v.reason, "User lacks pii_access"); -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginViolation { - /// Machine-readable violation identifier (e.g., `"missing_permission"`). - pub code: String, - - /// Short human-readable reason for the denial. - pub reason: String, - - /// Optional detailed explanation. - pub description: Option, - - /// Structured diagnostic data for logging or debugging. - pub details: HashMap, - - /// Name of the plugin that produced the violation. - /// Set by the framework after the plugin returns, not by the plugin itself. - pub plugin_name: Option, - - /// Protocol-level error code for the host to map to the wire format. - /// MCP: JSON-RPC codes (e.g., -32603). HTTP: status codes (e.g., 403). - /// Set by the plugin; the host interprets it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub proto_error_code: Option, -} - -impl PluginViolation { - /// Create a new violation with a code and reason. - pub fn new(code: impl Into, reason: impl Into) -> Self { - Self { - code: code.into(), - reason: reason.into(), - description: None, - details: HashMap::new(), - plugin_name: None, - proto_error_code: None, - } - } - - /// Attach a detailed description. - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - /// Attach structured diagnostic details. - pub fn with_details(mut self, details: HashMap) -> Self { - self.details = details; - self - } - - /// Attach a protocol-level error code. - pub fn with_proto_error_code(mut self, code: i64) -> Self { - self.proto_error_code = Some(code); - self - } -} - -impl std::fmt::Display for PluginViolation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "[{}] {}", self.code, self.reason) - } -} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs deleted file mode 100644 index ed4382cf..00000000 --- a/crates/cpex-core/src/executor.rs +++ /dev/null @@ -1,1240 +0,0 @@ -// Location: ./crates/cpex-core/src/executor.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// 5-phase plugin execution engine. -// -// Dispatches plugins in strict phase order: -// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET -// -// Each phase has different authority (block/modify) and scheduling -// (serial/parallel/background). The executor reads all scheduling -// decisions from PluginRef.trusted_config — never from the plugin. -// -// Extensions are passed separately from the payload and capability- -// filtered per plugin before dispatch. Extension modifications are -// merged back independently from payload modifications. -// -// Error handling respects the plugin's on_error setting: -// - Fail: propagate error, halt pipeline -// - Ignore: log error, continue pipeline -// - Disable: log error, mark plugin disabled, continue -// -// Mirrors the Python framework's PluginExecutor in -// cpex/framework/manager.py. - -use std::any::Any; -use std::fmt; -use std::sync::Arc; -use std::time::Duration; - -use tokio::time::timeout; -use tracing::{error, warn}; - -use crate::context::PluginContextTable; -use crate::error::PluginError; -use crate::extensions::filter_extensions; -use crate::hooks::payload::{Extensions, PluginPayload, WriteToken}; -use crate::plugin::OnError; -use crate::registry::{group_by_mode, HookEntry}; - -/// Configuration for the executor. -#[derive(Debug, Clone)] -pub struct ExecutorConfig { - /// Maximum execution time per plugin in seconds. - pub timeout_seconds: u64, - - /// Whether to halt on the first deny in concurrent mode. - pub short_circuit_on_deny: bool, -} - -impl Default for ExecutorConfig { - fn default() -> Self { - Self { - timeout_seconds: 30, - short_circuit_on_deny: true, - } - } -} - -/// Aggregate result from a full hook invocation across all phases. -/// -/// Wraps the final payload, extensions, any violation, and the -/// context table. Immutable by design — policy decisions cannot be -/// tampered with after the executor returns them. -/// -/// The caller should pass `context_table` into the next hook -/// invocation to preserve per-plugin local state across hooks in -/// the same request lifecycle. -/// -/// Background tasks are returned separately as [`BackgroundTasks`] -/// to keep the policy result immutable. -/// -/// `#[non_exhaustive]`: this result type keeps gaining fields as the -/// engine grows, so it is sealed against external struct-literal -/// construction and exhaustive destructuring — hosts read it, they don't -/// build it. Construct via [`Self::allowed_with`] / [`Self::denied`] plus -/// the `with_*` builders. New fields can then be added without breaking -/// downstream readers. -#[derive(Debug)] -#[non_exhaustive] -pub struct PipelineResult { - /// Whether the pipeline should continue processing. - /// `false` means a plugin denied — the pipeline was halted. - pub continue_processing: bool, - - /// The final payload after all modifications (type-erased). - /// `None` if the pipeline was denied before any modifications. - /// - /// Note this is `Some` on **every** allowed pipeline, carrying the - /// final payload whether or not a plugin touched it. To learn - /// whether anything actually changed, read [`Self::payload_modified`] - /// — do not compare payload contents, and do not read `is_some()` as - /// "was modified". - pub modified_payload: Option>, - - /// Whether any plugin's payload modification was accepted into - /// `modified_payload` above. - /// - /// Set by the phases that can modify (sequential, transform) at the - /// moment a handler's payload replaces the current one, so it - /// reflects what the executor actually applied: a plugin lacking the - /// modify capability, or one running in a read-only phase, does not - /// set it. - /// - /// This exists because the fact is knowable only here. A caller - /// comparing payloads afterwards cannot: the payload types are - /// type-erased with no equality, and content-shaped comparisons - /// (e.g. a message's text) are blind to whichever parts they don't - /// read. - pub payload_modified: bool, - - /// The final extensions after all modifications. - /// `None` if no plugin modified extensions. - pub modified_extensions: Option, - - /// The violation that caused a deny, if any. - pub violation: Option, - - /// Errors from plugins that ran with `on_error: ignore` or - /// `on_error: disable`. These plugins didn't halt the pipeline - /// (their on_error policy said to continue), but the caller - /// should still know the errors happened so it can log them in - /// a structured way, retry the affected plugin, or alert. - /// Empty when no plugin errored on a non-halt path. - /// Fire-and-forget errors live in `BackgroundTasks` instead. - pub errors: Vec, - - /// Optional metadata aggregated from plugins (telemetry, diagnostics). - pub metadata: Option, - - /// Plugin contexts indexed by plugin ID. Thread this into the - /// next hook invocation to preserve per-plugin `local_state`. - pub context_table: PluginContextTable, -} - -impl PipelineResult { - /// Pipeline completed — all plugins allowed. - pub fn allowed_with( - payload: Box, - extensions: Extensions, - context_table: PluginContextTable, - ) -> Self { - Self { - continue_processing: true, - modified_payload: Some(payload), - payload_modified: false, - modified_extensions: Some(extensions), - violation: None, - errors: Vec::new(), - metadata: None, - context_table, - } - } - - /// Record that a plugin's payload modification was applied. Chained - /// off [`Self::allowed_with`] by the executor, mirroring - /// [`Self::with_errors`]. - pub fn with_payload_modified(mut self, modified: bool) -> Self { - self.payload_modified = modified; - self - } - - /// Pipeline was denied by a plugin. - pub fn denied( - violation: crate::error::PluginViolation, - extensions: Extensions, - context_table: PluginContextTable, - ) -> Self { - Self { - continue_processing: false, - modified_payload: None, - payload_modified: false, - modified_extensions: Some(extensions), - violation: Some(violation), - errors: Vec::new(), - metadata: None, - context_table, - } - } - - /// Replace the errors vec on a constructed PipelineResult. Used by - /// the executor to attach errors collected from `on_error: ignore` - /// / `on_error: disable` plugins. - pub fn with_errors(mut self, errors: Vec) -> Self { - self.errors = errors; - self - } - - /// Whether this result represents a denial. - pub fn is_denied(&self) -> bool { - !self.continue_processing - } -} - -/// Handles to fire-and-forget background tasks spawned by the executor. -/// -/// Returned separately from [`PipelineResult`] so that the policy -/// result stays immutable. If not awaited, tasks complete on their -/// own in the background. Call `wait_for_background_tasks()` when you -/// need to ensure tasks have finished (tests, graceful shutdown, -/// audit flush). -pub struct BackgroundTasks { - tasks: Vec<(String, tokio::task::JoinHandle<()>)>, -} - -impl BackgroundTasks { - /// Create an empty set of background tasks. - pub fn empty() -> Self { - Self { tasks: Vec::new() } - } - - /// Create from a list of (plugin_name, handle) pairs. - fn from_handles(tasks: Vec<(String, tokio::task::JoinHandle<()>)>) -> Self { - Self { tasks } - } - - /// Whether there are any background tasks. - pub fn is_empty(&self) -> bool { - self.tasks.is_empty() - } - - /// Number of background tasks. - pub fn len(&self) -> usize { - self.tasks.len() - } - - /// Wait for all fire-and-forget background tasks to complete. - /// - /// Returns a list of errors from any tasks that panicked. - /// An empty list means all tasks completed successfully. - /// - /// Consumes `self` — each task handle can only be awaited once. - /// - /// If not called, background tasks still complete on their own. - /// Use this for tests, graceful shutdown, or when you need to - /// ensure audit/logging tasks have flushed before proceeding. - pub async fn wait_for_background_tasks(self) -> Vec { - let mut errors = Vec::new(); - for (plugin_name, handle) in self.tasks { - if let Err(e) = handle.await { - errors.push(crate::error::PluginError::Execution { - plugin_name, - message: format!("background task panicked: {}", e), - source: None, - code: None, - details: std::collections::HashMap::new(), - proto_error_code: None, - }); - } - } - errors - } -} - -impl fmt::Debug for BackgroundTasks { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("BackgroundTasks") - .field("count", &self.tasks.len()) - .finish() - } -} - -/// 5-phase plugin execution engine. -/// -/// Dispatches hooks through the phase pipeline: -/// -/// ```text -/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET -/// ``` -/// -/// The executor is stateless — all state comes from the arguments. -/// One executor instance can serve multiple concurrent hook invocations. -#[derive(Clone)] -pub struct Executor { - config: ExecutorConfig, -} - -impl Executor { - /// Create a new executor with the given configuration. - pub fn new(config: ExecutorConfig) -> Self { - Self { config } - } - - /// Execute a hook invocation through the 5-phase pipeline. - /// - /// # Arguments - /// - /// * `entries` — HookEntries for this hook, sorted by priority. - /// * `payload` — The typed payload (type-erased as Box). - /// * `extensions` — The full extensions (filtered per plugin before dispatch). - /// * `context_table` — Optional context table from a previous hook invocation. - /// If `None`, fresh contexts are created for each plugin. - /// - /// # Returns - /// - /// A tuple of: - /// - `PipelineResult` — immutable policy result with payload, - /// extensions, violation, and context table. - /// - `BackgroundTasks` — handles to fire-and-forget tasks. Call - /// `wait_for_background_tasks()` to await them, or drop to let - /// them complete in the background. - pub async fn execute( - &self, - entries: &[HookEntry], - payload: Box, - extensions: Extensions, - context_table: Option, - task_tracker: &tokio_util::task::TaskTracker, - ) -> (PipelineResult, BackgroundTasks) { - let mut ctx_table = context_table.unwrap_or_default(); - - if entries.is_empty() { - return ( - PipelineResult::allowed_with(payload, extensions, ctx_table), - BackgroundTasks::empty(), - ); - } - - // Group entries by mode (from trusted_config) - let (sequential, transform, audit, concurrent, fire_and_forget) = group_by_mode(entries); - - let mut current_payload = payload; - let mut current_extensions = extensions; - // Accumulator for errors from `on_error: ignore` / `on_error: - // disable` plugins across all phases. Surfaced to the caller - // via `PipelineResult.errors` so swallowed failures stay - // observable. Halt-condition errors (Fail, deny) skip this and - // become the violation directly. - let mut errors: Vec = Vec::new(); - // Sticky across both modifying phases: true once any handler's - // payload has been accepted. Reported on the result so callers - // read an exact signal instead of comparing payload contents. - let mut payload_modified = false; - - if let Some(v) = self - .run_serial_phase( - &sequential, - &mut current_payload, - &mut current_extensions, - &mut ctx_table, - true, // can_block - true, // can_modify - "SEQUENTIAL", - &mut errors, - &mut payload_modified, - ) - .await - { - return ( - PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors), - BackgroundTasks::empty(), - ); - } - - // Phase 2: TRANSFORM — serial, chained, can modify, cannot block. - // can_block=false means denials are suppressed (returns None). - self.run_serial_phase( - &transform, - &mut current_payload, - &mut current_extensions, - &mut ctx_table, - false, // can_block - true, // can_modify - "TRANSFORM", - &mut errors, - &mut payload_modified, - ) - .await; - - self.run_ref_phase( - &audit, - &*current_payload, - ¤t_extensions, - &ctx_table, - "AUDIT", - &mut errors, - ) - .await; - - if let Some(violation) = self - .run_concurrent_phase( - &concurrent, - &*current_payload, - ¤t_extensions, - &ctx_table, - &mut errors, - ) - .await - { - return ( - PipelineResult::denied(violation, current_extensions, ctx_table) - .with_errors(errors), - BackgroundTasks::empty(), - ); - } - - // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results. - // FAF errors don't go in PipelineResult.errors — they're delivered - // via BackgroundTasks::wait_for_background_tasks() instead. - let bg_handles = self.spawn_fire_and_forget( - &fire_and_forget, - &*current_payload, - ¤t_extensions, - &ctx_table, - task_tracker, - ); - - ( - PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) - .with_errors(errors) - .with_payload_modified(payload_modified), - BackgroundTasks::from_handles(bg_handles), - ) - } - - /// Run a serial phase — plugins execute one at a time, each seeing - /// the (possibly modified) payload from the previous. - /// - /// The framework retains ownership of the payload. Handlers receive - /// a borrow and clone only if they modify. Modified payloads in - /// the result replace the current payload. - /// - /// `payload_modified` is set to `true` when a handler's payload is - /// accepted, and never cleared — this is the only place that fact is - /// observable, so it's reported out rather than left to be guessed - /// from the resulting payload's contents. - /// - /// Each plugin's context is looked up in the context table (preserving - /// `local_state` from previous hooks) or created fresh. After execution, - /// `global_state` changes are merged back so the next plugin sees them. - #[allow(clippy::too_many_arguments)] // internal phase helper — args have distinct types and meaning - async fn run_serial_phase( - &self, - entries: &[HookEntry], - payload: &mut Box, - extensions: &mut Extensions, - ctx_table: &mut PluginContextTable, - can_block: bool, - can_modify: bool, - phase_label: &str, - errors: &mut Vec, - payload_modified: &mut bool, - ) -> Option { - for entry in entries { - // Borrow names/ids on the happy path — allocate only when - // building a violation or stashing the local_state back into - // the table. Previously `name.to_string()` + `id.to_string()` - // ran unconditionally on every plugin per invoke. - let plugin_name = entry.plugin_ref.name(); - let plugin_id = entry.plugin_ref.id(); - let on_error = entry.plugin_ref.trusted_config().on_error; - - // Take this plugin's context out of the table — pulls its stored - // local_state and seeds global_state from the canonical store. - // Replaces the previous values().last() seed, which was - // non-deterministic across HashMap iteration orders. - let mut ctx = ctx_table.take_context(plugin_id); - - // Filter extensions per plugin based on declared capabilities. - // Produces a filtered view with None for ungated slots. - // Also sets write tokens for plugins with write capabilities. - let capabilities: std::collections::HashSet = entry - .plugin_ref - .trusted_config() - .capabilities - .iter() - .cloned() - .collect(); - let mut filtered = filter_extensions(extensions, &capabilities); - - // Set write tokens based on capabilities - if capabilities.contains("write_headers") { - filtered.http_write_token = Some(WriteToken::new()); - } - if capabilities.contains("append_labels") { - filtered.labels_write_token = Some(WriteToken::new()); - } - if capabilities.contains("append_delegation") { - filtered.delegation_write_token = Some(WriteToken::new()); - } - - // Execute with timeout — handler borrows payload, gets filtered extensions - let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout( - timeout_dur, - entry.handler.invoke(&**payload, &filtered, &mut ctx), - ) - .await; - - match result { - Ok(Ok(result_box)) => { - if let Some(erased) = extract_erased(result_box) { - if !erased.continue_processing && can_block { - if let Some(mut v) = erased.violation { - v.plugin_name = Some(plugin_name.to_string()); - return Some(v); - } - } - - // Accept modifications - if can_modify { - if let Some(mp) = erased.modified_payload { - *payload = mp; - *payload_modified = true; - } - if let Some(mut owned) = erased.modified_extensions { - let mut immutable_ok = false; - if extensions.validate_immutable(&owned) { - // `merge_owned` enforces the tiers per - // *field*, gated on the write tokens that - // `owned` carries. It is not a slot swap: a - // field with no token keeps its canonical - // value, so an ungated edit is dropped - // rather than merged. Previously this arm - // was reached by a bare `else` that merged - // the plugin's whole capability-filtered - // view over canonical state — a plugin with - // no security capability could wipe the - // pipeline's labels by returning `custom`. - // - // The monotonic label check that used to - // live here moved into `merge_security`, - // where it applies unconditionally instead - // of only when `read_labels` was held. - // - // Authority is re-derived from *this* - // plugin's declared capabilities rather than - // read off the returned value. A handler is - // free to build its `OwnedExtensions` any - // way it likes — apl-cpex's synthetic route - // handler returns `cow_copy()` of a freshly - // accumulated `Extensions`, whose tokens - // `Clone` deliberately drops — so tokens - // surviving the round trip is a statement - // about plumbing, not about permission. The - // capability set is the real grant, and it - // cannot be widened by the return value. - owned.http_write_token = capabilities - .contains("write_headers") - .then(WriteToken::new); - owned.labels_write_token = capabilities - .contains("append_labels") - .then(WriteToken::new); - owned.delegation_write_token = capabilities - .contains("append_delegation") - .then(WriteToken::new); - immutable_ok = true; - } - // Monotonic security labels: a plugin that can see - // labels (`read_labels`) may only add them, never - // remove. A plugin without `read_labels` saw an empty - // label set in its filtered view, so an absent label - // there is not a removal. - let labels_ok = !capabilities.contains("read_labels") - || match (&extensions.security, &owned.security) { - (Some(orig), Some(new)) => { - new.labels.is_superset(&orig.labels) - }, - _ => true, - }; - - // Candidate-constraint authority: the folded routing - // constraint is the policy engine's output. Only a - // holder of `write_candidate_constraint` may create, - // change, or remove it; any other plugin that alters - // the slot (by value) is rejected. A plugin that - // leaves it untouched passes (the common case). - let constraint_ok = - extensions.candidate_constraint_write_ok(&owned, &capabilities); - - if !immutable_ok { - warn!( - "{} plugin '{}' violated immutable tier — \ - modified an immutable extension slot. \ - Extension changes rejected.", - phase_label, plugin_name - ); - } else if !labels_ok { - warn!( - "{} plugin '{}' violated monotonic tier — \ - removed a security label. \ - Extension changes rejected.", - phase_label, plugin_name - ); - } else if !constraint_ok { - warn!( - "{} plugin '{}' lacks `write_candidate_constraint` \ - — attempted to modify the policy engine's routing \ - constraint. Extension changes rejected.", - phase_label, plugin_name - ); - } else { - extensions.merge_owned(owned); - } - } - } - - // Plugin writes to ctx.global_state are committed back - // to the canonical store via store_context() below. - } - // If extract failed or no modifications — payload unchanged - }, - Ok(Err(e)) => { - error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); - match on_error { - OnError::Fail if can_block => { - let mut v = crate::error::PluginViolation::new( - "plugin_error", - format!("Plugin '{}' failed: {}", plugin_name, e), - ); - v.plugin_name = Some(plugin_name.to_string()); - return Some(v); - }, - // Any non-halt outcome (Fail-in-non-blocking-phase, - // Ignore, Disable): record the error so the caller - // sees it in PipelineResult.errors instead of - // having to read the warn-log. - OnError::Fail => { - warn!( - "{} plugin '{}' on_error=fail in non-blocking phase — not halting", - phase_label, plugin_name, - ); - errors.push((&e).into()); - }, - OnError::Ignore => { - errors.push((&e).into()); - }, - OnError::Disable => { - warn!( - "{} plugin '{}' disabled after error", - phase_label, plugin_name - ); - errors.push((&e).into()); - entry.plugin_ref.disable(); - }, - } - }, - Err(_) => { - error!("{} plugin '{}' timed out", phase_label, plugin_name); - let timeout_err = crate::error::PluginError::Timeout { - plugin_name: plugin_name.to_string(), - timeout_ms: timeout_dur.as_millis() as u64, - proto_error_code: None, - }; - match on_error { - OnError::Fail if can_block => { - let mut v = crate::error::PluginViolation::new( - "plugin_timeout", - format!("Plugin '{}' timed out", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - return Some(v); - }, - OnError::Fail => { - warn!( - "{} plugin '{}' on_error=fail (timeout) in non-blocking phase — not halting", - phase_label, plugin_name, - ); - errors.push((&timeout_err).into()); - }, - OnError::Ignore => { - errors.push((&timeout_err).into()); - }, - OnError::Disable => { - warn!( - "{} plugin '{}' disabled after timeout", - phase_label, plugin_name - ); - errors.push((&timeout_err).into()); - entry.plugin_ref.disable(); - }, - } - }, - } - - // Commit this plugin's context back to the table — replaces the - // canonical global_state with its (possibly modified) copy and - // stores the local_state for the next hook invocation. The - // global_state move is free; only the local_state insert allocates. - ctx_table.store_context(plugin_id, ctx); - } - - None // no denial - } - - /// Run a read-only phase — plugins receive &payload, results discarded. - async fn run_ref_phase( - &self, - entries: &[HookEntry], - payload: &dyn PluginPayload, - extensions: &Extensions, - ctx_table: &PluginContextTable, - phase_label: &str, - errors: &mut Vec, - ) { - for entry in entries { - let plugin_name = entry.plugin_ref.name().to_string(); - let plugin_id = entry.plugin_ref.id(); - let on_error = entry.plugin_ref.trusted_config().on_error; - // Read-only phase — snapshot the plugin's local_state and the - // canonical global_state, no merge-back. - let mut ctx = ctx_table.snapshot_context(plugin_id); - // Filter extensions per plugin — read-only, no write tokens. - let capabilities: std::collections::HashSet = entry - .plugin_ref - .trusted_config() - .capabilities - .iter() - .cloned() - .collect(); - let filtered = filter_extensions(extensions, &capabilities); - let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - - let result = timeout( - timeout_dur, - entry.handler.invoke(payload, &filtered, &mut ctx), - ) - .await; - - // Audit / fire-and-forget cannot block, so OnError::Fail can't - // halt the pipeline — but OnError::Disable must still take a - // repeatedly-failing plugin out of rotation. The previous code - // ignored on_error entirely, so Disable plugins kept failing - // forever no matter how many invocations errored. All non-halt - // failures also push a record into PipelineResult.errors. - match result { - Ok(Ok(_)) => {}, // read-only — discard result and ext_clone - Ok(Err(e)) => { - warn!( - "{} plugin '{}' error (ignored): {}", - phase_label, plugin_name, e - ); - errors.push((&e).into()); - if matches!(on_error, OnError::Disable) { - warn!( - "{} plugin '{}' disabled after error", - phase_label, plugin_name - ); - entry.plugin_ref.disable(); - } - }, - Err(_) => { - warn!( - "{} plugin '{}' timed out (ignored)", - phase_label, plugin_name - ); - let timeout_err = crate::error::PluginError::Timeout { - plugin_name: plugin_name.clone(), - timeout_ms: timeout_dur.as_millis() as u64, - proto_error_code: None, - }; - errors.push((&timeout_err).into()); - if matches!(on_error, OnError::Disable) { - warn!( - "{} plugin '{}' disabled after timeout", - phase_label, plugin_name - ); - entry.plugin_ref.disable(); - } - }, - } - } - } - - /// Run the concurrent phase — plugins execute truly in parallel. - /// Returns the first violation if any plugin denies. - /// - /// Built on `cpex_orchestration::run_branches`, the workspace's - /// shared "N async branches with abort-on-deny + per-branch timeout" - /// primitive (same crate apl-core's `Effect::Parallel` consumes). - /// Each branch returns a small `BranchData` carrying the plugin's - /// effective outcome (allow / deny / error). The orchestrator's - /// `is_deny` predicate inspects that — including the per-plugin - /// `on_error == Fail` case, which is treated as a halting outcome - /// so that an erroring/timing-out/panicking Fail-mode plugin - /// short-circuits the remaining branches the same way an explicit - /// deny does. Post-loop, we walk the outcomes in input order and - /// apply each plugin's `on_error` policy (Ignore / Disable) to - /// non-halting failures. - async fn run_concurrent_phase( - &self, - entries: &[HookEntry], - payload: &dyn PluginPayload, - extensions: &Extensions, - ctx_table: &PluginContextTable, - errors: &mut Vec, - ) -> Option { - use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; - - if entries.is_empty() { - return None; - } - - // Per-branch outcome. Carries just enough for post-loop policy - // application — plugin name / on_error are looked up via - // `entries[idx]` so we don't have to clone them into the - // future's captures. - enum BranchData { - Allow, - Deny(Option), - Error(Box), - } - - // Clone the payload once so each spawned task can borrow from - // an owned, 'static copy. Each task gets its own Arc'd clone. - let shared_payload: Arc> = Arc::new(payload.clone_boxed()); - let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - - // Snapshot per-entry on_error decisions BEFORE moving into - // futures — `is_deny` needs them at runtime to decide whether - // an Error outcome halts (Fail) or is logged (Ignore/Disable). - let on_error_by_idx: Vec = entries - .iter() - .map(|e| e.plugin_ref.trusted_config().on_error) - .collect(); - - // Build branch futures. Each does the timing-bounded handler - // invoke and extracts the type-erased result, returning a - // `BranchData` that the orchestrator's `is_deny` predicate can - // inspect without further type knowledge. - let mut branches: Vec> = Vec::with_capacity(entries.len()); - for entry in entries.iter() { - let handler = Arc::clone(&entry.handler); - let payload_clone = Arc::clone(&shared_payload); - let plugin_id = entry.plugin_ref.id(); - // Snapshot the plugin's local_state and the canonical global_state. - // Concurrent plugins do not merge back — each task owns its copy. - let mut ctx = ctx_table.snapshot_context(plugin_id); - let plugin_name = entry.plugin_ref.name().to_string(); - - // Filter per plugin — each may have different capabilities. - // Read-only, no write tokens. Wrap in Arc for 'static spawn. - let capabilities: std::collections::HashSet = entry - .plugin_ref - .trusted_config() - .capabilities - .iter() - .cloned() - .collect(); - let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - - branches.push(Box::pin(async move { - match handler.invoke(&**payload_clone, &filtered, &mut ctx).await { - Ok(result_box) => match extract_erased(result_box) { - Some(erased) if !erased.continue_processing => { - let violation = erased.violation.map(|mut v| { - v.plugin_name = Some(plugin_name); - v - }); - BranchData::Deny(violation) - }, - // `Some(..)` with continue_processing=true, OR - // `None` (downcast failed — historically logged - // and treated as Allow) both fall through. - _ => BranchData::Allow, - }, - Err(e) => BranchData::Error(e), - } - })); - } - - let cfg = BranchConfig { - timeout_per_branch: Some(timeout_dur), - short_circuit_on_deny: self.config.short_circuit_on_deny, - }; - - // `is_deny` halts on explicit Deny only. It can't halt on - // Error/Timeout/Panic because the predicate sees only the - // value, not the branch index, so it can't read the per-entry - // `on_error` policy. Halting on those failures is handled in - // the post-loop: the first Fail-policy failure becomes the - // returned violation, and any in-flight tasks drop when the - // JoinSet inside `run_branches` goes out of scope. - // - // The original implementation called `set.abort_all()` on - // Fail-class errors too. The behavioural difference: the - // post-loop now waits for all branches to finish (or hit - // their own timeout) before returning. For the slow-plugin - // abort test that's fine — that test exercises the Deny - // path, which still goes through `is_deny` + abort_all. - let outcomes = run_branches(branches, cfg, |v: &BranchData| { - matches!(v, BranchData::Deny(_)) - }) - .await; - - // Post-loop: walk outcomes in input order applying per-plugin - // policy. First halting outcome wins. - let mut first_violation: Option = None; - - for (idx, outcome) in outcomes.into_iter().enumerate() { - let entry = &entries[idx]; - let plugin_name = entry.plugin_ref.name(); - let on_error = on_error_by_idx[idx]; - - match outcome { - BranchOutcome::Completed(BranchData::Allow) => {}, - BranchOutcome::Completed(BranchData::Deny(opt_v)) => { - let violation = opt_v.unwrap_or_else(|| { - let mut v = crate::error::PluginViolation::new( - "concurrent_deny", - format!("Plugin '{}' denied", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - v - }); - if first_violation.is_none() { - first_violation = Some(violation); - } - }, - BranchOutcome::Completed(BranchData::Error(e)) => match on_error { - OnError::Fail => { - if first_violation.is_none() { - let mut v = crate::error::PluginViolation::new( - "plugin_error", - format!("Plugin '{}' failed: {}", plugin_name, e), - ); - v.plugin_name = Some(plugin_name.to_string()); - first_violation = Some(v); - } - }, - OnError::Ignore => { - warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); - errors.push((&*e).into()); - }, - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); - errors.push((&*e).into()); - entry.plugin_ref.disable(); - }, - }, - BranchOutcome::TimedOut => { - let timeout_err = crate::error::PluginError::Timeout { - plugin_name: plugin_name.to_string(), - timeout_ms: timeout_dur.as_millis() as u64, - proto_error_code: None, - }; - match on_error { - OnError::Fail => { - if first_violation.is_none() { - let mut v = crate::error::PluginViolation::new( - "plugin_timeout", - format!("Plugin '{}' timed out", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - first_violation = Some(v); - } - }, - OnError::Ignore => { - warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); - errors.push((&timeout_err).into()); - }, - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); - errors.push((&timeout_err).into()); - entry.plugin_ref.disable(); - }, - } - }, - BranchOutcome::Panicked(s) => { - error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, s); - let panic_err = crate::error::PluginError::Execution { - plugin_name: plugin_name.to_string(), - message: format!("task panicked: {}", s), - source: None, - code: Some("panic".into()), - details: std::collections::HashMap::new(), - proto_error_code: None, - }; - match on_error { - OnError::Fail => { - if first_violation.is_none() { - let mut v = crate::error::PluginViolation::new( - "plugin_panic", - format!("Plugin '{}' task panicked: {}", plugin_name, s), - ); - v.plugin_name = Some(plugin_name.to_string()); - first_violation = Some(v); - } - }, - OnError::Ignore => { - warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); - errors.push((&panic_err).into()); - }, - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); - errors.push((&panic_err).into()); - entry.plugin_ref.disable(); - }, - } - }, - BranchOutcome::Aborted => { - // Cancelled because an earlier branch hit a halt - // condition under short_circuit_on_deny. Intentional - // — no error to record. - }, - } - } - - first_violation - } - - /// Spawn fire-and-forget handlers as background tasks. - /// - /// Each handler runs in its own `tokio::spawn` — the pipeline does - /// not wait for them. Errors and timeouts are logged but have no - /// effect on the pipeline result. - /// - /// Returns the plugin name and join handle for each spawned task - /// so they can be stored on `PipelineResult` for optional awaiting - /// via `wait_for_background_tasks()`. - fn spawn_fire_and_forget( - &self, - entries: &[HookEntry], - payload: &dyn PluginPayload, - extensions: &Extensions, - ctx_table: &PluginContextTable, - task_tracker: &tokio_util::task::TaskTracker, - ) -> Vec<(String, tokio::task::JoinHandle<()>)> { - if entries.is_empty() { - return Vec::new(); - } - - let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - - let mut handles = Vec::with_capacity(entries.len()); - - for entry in entries { - let plugin_name = entry.plugin_ref.name().to_string(); - let handler = Arc::clone(&entry.handler); - let owned_payload = payload.clone_boxed(); - // Snapshot per plugin so fire-and-forget tasks see their stored - // local_state from prior hooks, not just an empty context. - let mut ctx = ctx_table.snapshot_context(entry.plugin_ref.id()); - let dur = timeout_dur; - let name_for_log = plugin_name.clone(); - - // Filter per plugin, read-only, no write tokens - let capabilities: std::collections::HashSet = entry - .plugin_ref - .trusted_config() - .capabilities - .iter() - .cloned() - .collect(); - let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - - // Spawn through TaskTracker so `PluginManager::shutdown()` - // can drain in-flight fire-and-forget tasks before tearing - // down. The returned JoinHandle is the same shape as - // tokio::spawn's, so callers using BackgroundTasks still - // wait_for_background_tasks() over their own handles. - let handle = task_tracker.spawn(async move { - let result = - timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await; - - match result { - Ok(Ok(_)) => {}, // discard - Ok(Err(e)) => { - warn!( - "FIRE_AND_FORGET plugin '{}' error (ignored): {}", - name_for_log, e - ); - }, - Err(_) => { - warn!( - "FIRE_AND_FORGET plugin '{}' timed out (ignored)", - name_for_log - ); - }, - } - }); - - handles.push((plugin_name, handle)); - } - - handles - } -} - -impl Default for Executor { - fn default() -> Self { - Self::new(ExecutorConfig::default()) - } -} - -// SerialResult removed — run_serial_phase now returns Option directly. - -/// Common fields extracted from a type-erased PluginResult. -/// -/// Handlers return `Box` which wraps this struct. The -/// executor extracts it via [`extract_erased()`] to read the -/// control flow fields without knowing the concrete payload type. -pub struct ErasedResultFields { - pub continue_processing: bool, - pub modified_payload: Option>, - pub modified_extensions: Option, - pub violation: Option, -} - -/// Extract erased result fields from a type-erased handler result. -/// -/// Takes ownership of the Box — the executor consumes the result. -/// Logs a warning if the downcast fails (indicates a handler returned -/// the wrong type — a framework bug, not a plugin error). -pub fn extract_erased(result: Box) -> Option { - match result.downcast::() { - Ok(b) => Some(*b), - Err(_) => { - warn!("extract_erased: downcast failed — handler returned unexpected type"); - None - }, - } -} - -/// Convert a typed `PluginResult

` into `ErasedResultFields`. -/// -/// Called by `TypedHandlerAdapter` to bridge between the typed -/// result and the executor's type-erased dispatch. -pub fn erase_result( - result: crate::hooks::PluginResult

, -) -> Box { - Box::new(ErasedResultFields { - continue_processing: result.continue_processing, - modified_payload: result - .modified_payload - .map(|p| Box::new(p) as Box), - modified_extensions: result.modified_extensions, - violation: result.violation, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::payload::PluginPayload; - use crate::hooks::PluginResult; - - #[derive(Debug, Clone)] - #[allow(dead_code)] // test fixture — typed shape is the point, not field reads - struct TestPayload { - value: String, - } - crate::impl_plugin_payload!(TestPayload); - - #[test] - fn test_erase_result_allow() { - let result: PluginResult = PluginResult::allow(); - let erased = erase_result(result); - let fields = extract_erased(erased).unwrap(); - assert!(fields.continue_processing); - assert!(fields.violation.is_none()); - assert!(fields.modified_payload.is_none()); - } - - #[test] - fn test_erase_result_deny() { - let result: PluginResult = - PluginResult::deny(crate::error::PluginViolation::new("test", "denied")); - let erased = erase_result(result); - let fields = extract_erased(erased).unwrap(); - assert!(!fields.continue_processing); - assert_eq!(fields.violation.as_ref().unwrap().code, "test"); - } - - #[test] - fn test_erase_result_modify_payload() { - let result: PluginResult = PluginResult::modify_payload(TestPayload { - value: "modified".into(), - }); - let erased = erase_result(result); - let fields = extract_erased(erased).unwrap(); - assert!(fields.continue_processing); - assert!(fields.modified_payload.is_some()); - } - - #[test] - fn test_erase_result_modify_extensions() { - let mut security = crate::extensions::SecurityExtension::default(); - security.add_label("PII"); - let ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - let owned = ext.cow_copy(); - let result: PluginResult = PluginResult::modify_extensions(owned); - let erased = erase_result(result); - let fields = extract_erased(erased).unwrap(); - assert!(fields.continue_processing); - assert!(fields.modified_extensions.is_some()); - let sec = fields - .modified_extensions - .as_ref() - .unwrap() - .security - .as_ref() - .unwrap(); - assert!(sec.has_label("PII")); - } - - #[test] - fn test_pipeline_result_allowed() { - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let result = - PipelineResult::allowed_with(payload, Extensions::default(), PluginContextTable::new()); - assert!(result.continue_processing); - assert!(result.modified_payload.is_some()); - assert!(result.violation.is_none()); - assert!( - !result.payload_modified, - "carrying a payload is not the same as a plugin having changed it" - ); - } - - #[test] - fn test_pipeline_result_denied() { - let violation = crate::error::PluginViolation::new("test", "denied"); - let result = - PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new()); - assert!(!result.continue_processing); - assert!(result.modified_payload.is_none()); - assert!(result.violation.is_some()); - } - - #[tokio::test] - async fn test_executor_empty_entries() { - let executor = Executor::default(); - let tracker = tokio_util::task::TaskTracker::new(); - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = executor - .execute(&[], payload, Extensions::default(), None, &tracker) - .await; - assert!(result.continue_processing); - assert!(result.modified_payload.is_some()); - } -} diff --git a/crates/cpex-core/src/extensions/agent.rs b/crates/cpex-core/src/extensions/agent.rs deleted file mode 100644 index f6eb9c3b..00000000 --- a/crates/cpex-core/src/extensions/agent.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/agent.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// AgentExtension — session, conversation, agent lineage. -// Mirrors cpex/framework/extensions/agent.py. - -use serde::{Deserialize, Serialize}; - -/// Conversation history context. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ConversationContext { - /// Recent conversation history (lightweight summaries). - #[serde(default)] - pub history: Vec, - - /// LLM-generated summary of the conversation. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub summary: Option, - - /// Detected topics in the conversation. - #[serde(default)] - pub topics: Vec, -} - -/// Agent execution context extension. -/// -/// Carries session tracking, conversation context, multi-agent -/// lineage, and the original user/agent input. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AgentExtension { - /// Original user/agent input that triggered this action. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input: Option, - - /// Broad user/agent session identifier. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - - /// Specific dialogue/task identifier within a session. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub conversation_id: Option, - - /// Position within the conversation (0-indexed). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn: Option, - - /// Identifier of the agent that produced this message. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_id: Option, - - /// If spawned by another agent, the parent's ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_agent_id: Option, - - /// Optional conversation context with history. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub conversation: Option, -} diff --git a/crates/cpex-core/src/extensions/authorization.rs b/crates/cpex-core/src/extensions/authorization.rs deleted file mode 100644 index e9a42d4d..00000000 --- a/crates/cpex-core/src/extensions/authorization.rs +++ /dev/null @@ -1,87 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/authorization.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// AuthorizationDetail — RFC 9396 Rich Authorization Requests. -// -// Carried on DelegationHop alongside `scopes_granted`. Each hop can narrow -// the details structurally (drop entries, remove actions, add constraints). -// The narrowing-check helper lives elsewhere (framework enforcement at the -// TokenDelegate boundary). - -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -/// A single RFC 9396 authorization_details entry. -/// -/// `type` is required (renamed `detail_type` here to avoid the Rust -/// keyword). The remaining fields are optional per the RFC. API-specific -/// extension fields are captured in `extra` via serde flatten. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct AuthorizationDetail { - #[serde(rename = "type")] - pub detail_type: String, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub locations: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actions: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub datatypes: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub identifier: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub privileges: Option>, - - /// API-specific fields not covered by the named RFC 9396 fields above. - /// Subsetting checks treat these opaquely (exact equality). - #[serde(flatten)] - pub extra: BTreeMap, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serde_roundtrip_with_rfc9396_keyword() { - let detail = AuthorizationDetail { - detail_type: "tool_invocation".into(), - actions: Some(vec!["read".into()]), - identifier: Some("get_compensation".into()), - ..Default::default() - }; - let json = serde_json::to_string(&detail).unwrap(); - // The `type` field on the wire, not `detail_type`. - assert!(json.contains(r#""type":"tool_invocation""#)); - assert!(!json.contains("detail_type")); - - let back: AuthorizationDetail = serde_json::from_str(&json).unwrap(); - assert_eq!(back, detail); - } - - #[test] - fn extra_fields_round_trip() { - let json = r#"{ - "type": "payment", - "actions": ["initiate"], - "amount": "100.00", - "currency": "USD" - }"#; - let detail: AuthorizationDetail = serde_json::from_str(json).unwrap(); - assert_eq!(detail.detail_type, "payment"); - assert_eq!( - detail.extra.get("amount").and_then(|v| v.as_str()), - Some("100.00") - ); - assert_eq!( - detail.extra.get("currency").and_then(|v| v.as_str()), - Some("USD") - ); - } -} diff --git a/crates/cpex-core/src/extensions/completion.rs b/crates/cpex-core/src/extensions/completion.rs deleted file mode 100644 index 2c3ad14d..00000000 --- a/crates/cpex-core/src/extensions/completion.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/completion.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CompletionExtension — LLM completion information. -// Mirrors cpex/framework/extensions/completion.py. - -use serde::{Deserialize, Serialize}; - -/// Why the model stopped generating. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StopReason { - /// Natural end of message. - End, - /// Complete response (Harmony format). - Return, - /// Tool/function invocation. - Call, - /// Hit token limit. - MaxTokens, - /// Hit custom stop sequence. - StopSequence, -} - -/// Token usage statistics. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TokenUsage { - /// Input tokens consumed. - #[serde(default)] - pub input_tokens: u32, - - /// Output tokens generated. - #[serde(default)] - pub output_tokens: u32, - - /// Total tokens (input + output). - #[serde(default)] - pub total_tokens: u32, -} - -/// LLM completion information. -/// -/// Immutable — set after the LLM responds. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CompletionExtension { - /// Why the model stopped generating. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stop_reason: Option, - - /// Token usage statistics. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - /// Model identifier. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - - /// Raw response format (chatml, harmony, gemini, anthropic). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub raw_format: Option, - - /// Creation timestamp (ISO 8601). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_at: Option, - - /// Response latency in milliseconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub latency_ms: Option, -} diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs deleted file mode 100644 index 899a155e..00000000 --- a/crates/cpex-core/src/extensions/container.rs +++ /dev/null @@ -1,1301 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/container.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Extensions and OwnedExtensions — typed containers for all -// extension data passed separately from the payload to handlers. -// -// Extensions is fully immutable (all Arc) — zero-copy shareable. -// OwnedExtensions is the plugin's writeable workspace, created by -// cow_copy(), returned in PluginResult::modify_extensions(). - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use super::agent::AgentExtension; -use super::completion::CompletionExtension; -use super::delegation::DelegationExtension; -use super::framework::FrameworkExtension; -use super::guarded::{Guarded, WriteToken}; -use super::http::HttpExtension; -use super::llm::LLMExtension; -use super::mcp::MCPExtension; -use super::meta::MetaExtension; -use super::provenance::ProvenanceExtension; -use super::raw_credentials::RawCredentialsExtension; -use super::request::RequestExtension; -use super::routing::{CandidateConstraintExtension, CAP_WRITE_CANDIDATE_CONSTRAINT}; -use super::security::SecurityExtension; - -/// Typed container for all message extensions. -/// -/// All slots are `Arc` — fully immutable, zero-copy shareable. -/// Cloning is all refcount bumps. `filter_extensions()` creates a -/// filtered view by setting unwanted slots to `None` (still all Arc, -/// no deep copies). Plugins receive `&Extensions` (zero cost). -/// -/// To modify, plugins call `cow_copy()` which returns an -/// `OwnedExtensions` with mutable/monotonic/guarded slots cloned -/// out of Arc and write tokens propagated. -/// -/// Mirrors Python's `cpex.framework.extensions.Extensions`. -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct Extensions { - /// Execution environment and request tracing (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request: Option>, - - /// Agent execution context — session, conversation, lineage (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option>, - - /// HTTP headers (frozen as Arc — unfrozen in OwnedExtensions). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub http: Option>, - - /// Security — labels, classification, subject (frozen as Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub security: Option>, - - /// Backend candidate constraint emitted by APL `restrict` effects - /// (frozen as Arc — cloned out in OwnedExtensions). The policy - /// engine writes it; the host router reads it typed to narrow its - /// candidate set. A routing directive, never an access decision. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub candidate_constraint: Option>, - - /// Delegation chain (frozen as Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option>, - - /// Raw credential material — Layer 3 of the credential storage - /// model (see `RawCredentialsExtension` docs). Capability-gated; - /// `filter_extensions` strips this slot for plugins without - /// `read_inbound_credentials` / `read_delegated_tokens`. Token - /// fields inside this extension are `#[serde(skip)]`, so any - /// serialization (logs, audit dumps, hot-reload snapshots) drops - /// secret material even when the slot itself survives — so no - /// out-of-process plugin sees token bytes over the generic - /// `extensions` channel. Plaintext can still reach a worker over a - /// host's purpose-built, capability-gated side channel; see - /// `RawCredentialsExtension` for the conditions and the residual - /// exposure. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub raw_credentials: Option>, - - /// MCP entity metadata (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp: Option>, - - /// LLM completion information (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub completion: Option>, - - /// Origin and message threading (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provenance: Option>, - - /// Model identity and capabilities (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option>, - - /// Agentic framework context (immutable). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub framework: Option>, - - /// Host-provided operational metadata (immutable). - #[serde(default)] - pub meta: Option>, - - /// Custom extensions (frozen as Arc — unfrozen in OwnedExtensions). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom: Option>>, - - /// Write tokens — set by the executor per plugin, NOT serialized. - /// Used by `cow_copy()` to propagate write access to OwnedExtensions. - #[serde(skip)] - pub http_write_token: Option, - #[serde(skip)] - pub labels_write_token: Option, - #[serde(skip)] - pub delegation_write_token: Option, -} - -impl Clone for Extensions { - /// All Arc bumps — zero data copies. Write tokens are NOT cloned. - fn clone(&self) -> Self { - Self { - request: self.request.clone(), - agent: self.agent.clone(), - http: self.http.clone(), - security: self.security.clone(), - candidate_constraint: self.candidate_constraint.clone(), - delegation: self.delegation.clone(), - raw_credentials: self.raw_credentials.clone(), - mcp: self.mcp.clone(), - completion: self.completion.clone(), - provenance: self.provenance.clone(), - llm: self.llm.clone(), - framework: self.framework.clone(), - meta: self.meta.clone(), - custom: self.custom.clone(), - http_write_token: None, - labels_write_token: None, - delegation_write_token: None, - } - } -} - -impl Extensions { - /// Create a copy-on-write owned copy for modification. - /// - /// Immutable slots share the same `Arc` (refcount bump, ~1ns). - /// Mutable/monotonic/guarded slots are cloned out of Arc into - /// owned values — the plugin can modify them directly. - /// Write tokens are propagated from the original. - /// - /// # Usage - /// - /// ```ignore - /// fn handle(&self, payload: &P, ext: &Extensions, ctx: &mut PluginContext) -> PluginResult

{ - /// let mut owned = ext.cow_copy(); - /// owned.security.as_mut().unwrap().add_label("CHECKED"); - /// if let Some(ref token) = owned.http_write_token { - /// owned.http.as_mut().unwrap().write(token).set_header("X-Foo", "bar"); - /// } - /// PluginResult::modify_extensions(owned) - /// } - /// ``` - pub fn cow_copy(&self) -> OwnedExtensions { - OwnedExtensions { - // Immutable — same Arc pointers - request: self.request.clone(), - agent: self.agent.clone(), - mcp: self.mcp.clone(), - completion: self.completion.clone(), - provenance: self.provenance.clone(), - llm: self.llm.clone(), - framework: self.framework.clone(), - meta: self.meta.clone(), - raw_credentials: self.raw_credentials.clone(), - - // Mutable/monotonic/guarded — cloned out of Arc into owned - http: self.http.as_ref().map(|arc| Guarded::new((**arc).clone())), - security: self.security.as_ref().map(|arc| (**arc).clone()), - candidate_constraint: self - .candidate_constraint - .as_ref() - .map(|arc| (**arc).clone()), - delegation: self.delegation.as_ref().map(|arc| (**arc).clone()), - custom: self.custom.as_ref().map(|arc| (**arc).clone()), - - http_write_token: if self.http_write_token.is_some() { - Some(WriteToken::new()) - } else { - None - }, - labels_write_token: if self.labels_write_token.is_some() { - Some(WriteToken::new()) - } else { - None - }, - delegation_write_token: if self.delegation_write_token.is_some() { - Some(WriteToken::new()) - } else { - None - }, - } - } - - /// Validate that immutable slots were not tampered with. - /// - /// A slot that is `None` in modified (because capability filtering - /// hid it from the plugin) is always valid — the plugin never saw - /// it. Only flag as tampering when both are `Some` with different - /// Arc pointers, or when the original is `None` but modified is - /// `Some` (the plugin fabricated a slot it shouldn't have). - pub fn validate_immutable(&self, modified: &OwnedExtensions) -> bool { - fn ptr_eq_opt(a: &Option>, b: &Option>) -> bool { - match (a, b) { - (Some(a), Some(b)) => Arc::ptr_eq(a, b), - (None, None) => true, - (_, None) => true, // plugin never saw it — not tampering - (None, Some(_)) => false, // plugin fabricated a slot - } - } - - ptr_eq_opt(&self.request, &modified.request) - && ptr_eq_opt(&self.agent, &modified.agent) - && ptr_eq_opt(&self.mcp, &modified.mcp) - && ptr_eq_opt(&self.completion, &modified.completion) - && ptr_eq_opt(&self.provenance, &modified.provenance) - && ptr_eq_opt(&self.llm, &modified.llm) - && ptr_eq_opt(&self.framework, &modified.framework) - && ptr_eq_opt(&self.meta, &modified.meta) - // NOTE: `raw_credentials` is INTENTIONALLY excluded from the - // immutable check. Framework orchestrators (apl-cpex's - // DelegationPluginInvoker) legitimately write - // `delegated_tokens.*` via the shared Mutex during route - // evaluation, producing a new Arc by the time the synthetic - // handler returns. Per-plugin write authority is enforced at - // the capability layer (`write_delegated_tokens` / - // `write_inbound_credentials`), not at this pointer-equality - // gate. Until cap-tier-aware merge lands, treat raw_credentials - // as merge-able like `security` and `delegation`. - } - - /// Whether `modified` may write the `candidate_constraint` slot, given - /// the plugin's `capabilities`. - /// - /// The routing constraint is the policy engine's *output*: only a holder - /// of [`CAP_WRITE_CANDIDATE_CONSTRAINT`] may create, change, or remove it. - /// A plugin that leaves the slot at its original value (or holds the cap) - /// passes; any other plugin that alters it by value is rejected, so a - /// downstream plugin cannot overwrite, drop, or forge the constraint. - /// Compared by value (not `Arc` pointer) because the slot legitimately - /// round-trips through a fresh `Arc` on every merge. - pub fn candidate_constraint_write_ok( - &self, - modified: &OwnedExtensions, - capabilities: &HashSet, - ) -> bool { - capabilities.contains(CAP_WRITE_CANDIDATE_CONSTRAINT) - || modified.candidate_constraint.as_ref() == self.candidate_constraint.as_deref() - } - - /// Merge an OwnedExtensions back into this Extensions, field by field. - /// - /// # Why per-field and not per-slot - /// - /// `owned` derives from `cow_copy()` of a **capability-filtered** view, so - /// every sub-field the plugin could not read is *empty* in it, not merely - /// unchanged. `filter_extensions` blanks `security.labels`, `.subject`, - /// `.client`, `.caller_workload`, and `.this_workload` for a plugin without - /// the matching read capability. A whole-slot swap therefore writes those - /// blanks over canonical state: a plugin with no security capability at all - /// could wipe the pipeline's security labels just by returning a `custom` - /// value, because the swap took its empty-labels view along for the ride. - /// - /// So each writable field is merged individually and only when the matching - /// write token is present on `owned`. The token is minted by the executor - /// from the plugin's declared capabilities (`WriteToken::new()` is - /// `pub(crate)`), so it is the authority for *this* field, not for the slot - /// it happens to live in. Fields with no write capability in the tier model - /// — `security.subject`, `.auth_method`, `.client`, the workload identities - /// — are never taken from `owned` no matter which token it carries. - /// - /// Ambiguity fails closed: an absent token, an absent slot, or a - /// non-monotonic edit leaves the canonical value standing. - pub fn merge_owned(&mut self, owned: OwnedExtensions) { - self.merge_http(owned.http, owned.http_write_token.as_ref()); - self.merge_security(owned.security, owned.labels_write_token.as_ref()); - self.candidate_constraint = owned.candidate_constraint.map(Arc::new); - self.merge_delegation(owned.delegation, owned.delegation_write_token.as_ref()); - - // `custom` is the Mutable tier with `AccessPolicy::Unrestricted` — no - // capability gates it, so it merges without a token. It is also the - // only slot in that position, which is why it must not be able to drag - // a gated slot along with it. - self.custom = owned.custom.map(Arc::new); - // `raw_credentials` is shared by Arc in `OwnedExtensions` — - // plugins don't mutate it directly. But framework orchestrators - // (apl-cpex's DelegationPluginInvoker) DO write delegated_tokens - // / inbound_tokens through the shared `Arc>` - // before the synthetic handler returns. We must propagate - // those writes back so callers of `invoke_named` see the - // minted tokens in `PipelineResult.modified_extensions`. - // Without this, `delegate(...)` steps silently lose their - // results at the executor merge boundary. - if owned.raw_credentials.is_some() { - self.raw_credentials = owned.raw_credentials; - } - } - - /// Merge the guarded `http` slot — header maps and the request line. - /// - /// `write_headers` authorizes *headers*, which is what the capability is - /// named for. The request line (`method`, `path`, `host`, `scheme`) is - /// host-populated request identity that policies gate on, so it is always - /// preserved from canonical state and never taken from `owned`. `host` in - /// particular must come from a validated authority (see `HttpExtension`), - /// which a plugin's return value is not. - fn merge_http(&mut self, owned: Option>, token: Option<&WriteToken>) { - let Some(owned) = owned else { return }; - if token.is_none() { - return; - } - let owned = owned.into_inner(); - - let mut merged = match self.http.as_ref() { - Some(canonical) => (**canonical).clone(), - // No canonical http slot: there is no request line to preserve, so - // the returned headers stand on their own. - None => HttpExtension::default(), - }; - merged.request_headers = owned.request_headers; - merged.response_headers = owned.response_headers; - self.http = Some(Arc::new(merged)); - } - - /// Merge the `security` slot — labels only, and only as an append. - /// - /// Labels are the Monotonic tier: `append_labels` permits growing the set - /// and nothing else. A returned set that is not a superset of canonical is a - /// removal attempt and is dropped whole rather than partially applied — a - /// laundered declassification is the exact attack this gate exists for, and - /// removal requires a `DeclassifierToken` no plugin can construct. - /// - /// Every other field on the slot is Immutable in the tier model with - /// `write_cap: None` — `subject`, `auth_method`, `client`, `caller_workload`, - /// `this_workload`, `classification`, `objects`, `data`. A labels token does - /// not reach them, so they are preserved from canonical state. Without this, - /// `append_labels` alone would let a plugin rewrite the authenticated - /// subject and the auth method it was authenticated by. - fn merge_security(&mut self, owned: Option, token: Option<&WriteToken>) { - let Some(owned) = owned else { return }; - if token.is_none() { - return; - } - - let mut merged = match self.security.as_ref() { - Some(canonical) => (**canonical).clone(), - None => SecurityExtension::default(), - }; - - // Monotonic: fold in the additions, never assign the returned set. - // Assignment would drop any canonical label the plugin could not see, - // and folding makes a filtered-away label unremovable by construction. - if !owned.labels.is_superset(&merged.labels) { - // Not a superset of what the plugin was shown either — an explicit - // removal attempt. Drop the whole edit; the canonical set stands. - return; - } - for label in owned.labels.iter() { - merged.labels.add_label(label.clone()); - } - - self.security = Some(Arc::new(merged)); - } - - /// Merge the `delegation` slot — append-only chain growth, validated. - /// - /// The chain is the Monotonic tier: `append_delegation` permits appending - /// hops. A returned chain must therefore *extend* the canonical one — same - /// length-or-longer, with every existing hop unchanged. A shortened or - /// rewritten chain is a forged lineage (dropping the hop that recorded a - /// scope narrowing widens effective authority) and is dropped whole. - /// - /// `depth` and `delegated` are recomputed from the merged chain rather than - /// taken from the wire, so a plugin cannot claim a depth its chain does not - /// have. `origin_subject_id` is the chain's root identity and is preserved - /// once canonical state has one. - fn merge_delegation(&mut self, owned: Option, token: Option<&WriteToken>) { - let Some(owned) = owned else { return }; - if token.is_none() { - return; - } - - let canonical = self.delegation.as_ref().map(|arc| (**arc).clone()); - let mut merged = canonical.clone().unwrap_or_default(); - - if let Some(canonical) = canonical.as_ref() { - if !chain_extends(&canonical.chain, &owned.chain) { - return; - } - } - merged.chain = owned.chain; - - // Derived from the chain, not asserted by the plugin. - // Cast is safe: a chain with > u32::MAX hops would have failed - // allocation long ago. - merged.depth = merged.chain.len() as u32; - merged.delegated = !merged.chain.is_empty(); - - // The root of the chain cannot be re-pointed once established; the - // current actor legitimately advances with each appended hop. - if merged.origin_subject_id.is_none() { - merged.origin_subject_id = owned.origin_subject_id; - } - merged.actor_subject_id = owned.actor_subject_id; - merged.age_seconds = owned.age_seconds; - - self.delegation = Some(Arc::new(merged)); - } -} - -/// True when `returned` is `canonical` plus zero or more appended hops. -/// -/// Hops are compared on the fields that carry authority — subject, audience, -/// granted scopes, and strategy. A rewrite of any of those on an existing hop is -/// not an append, so the whole edit is refused. -/// -/// Public so out-of-process hosts can apply the same validation to a chain -/// arriving over the wire before it reaches the merge, instead of reimplementing -/// the definition of "append-only" and drifting from it. -pub fn chain_extends( - canonical: &[super::delegation::DelegationHop], - returned: &[super::delegation::DelegationHop], -) -> bool { - if returned.len() < canonical.len() { - return false; - } - canonical - .iter() - .zip(returned.iter()) - .all(|(before, after)| { - before.subject_id == after.subject_id - && before.subject_type == after.subject_type - && before.audience == after.audience - && before.scopes_granted == after.scopes_granted - && before.strategy == after.strategy - }) -} - -/// Owned copy of extensions for plugin modification. -/// -/// Returned by `Extensions::cow_copy()`. Immutable slots share -/// the same `Arc` pointers as the original (zero copy). Mutable, -/// monotonic, and guarded slots are cloned into owned values that -/// the plugin can modify directly. -/// -/// Plugins return this in `PluginResult::modify_extensions()`. -/// The executor validates (immutable unchanged, monotonic superset) -/// and merges back into the pipeline's `Extensions`. -/// -/// Hosts never see this type — the executor converts to `Extensions` -/// before building `PipelineResult`. -#[derive(Debug)] -pub struct OwnedExtensions { - // Immutable — same Arc pointers as original - pub request: Option>, - pub agent: Option>, - pub mcp: Option>, - pub completion: Option>, - pub provenance: Option>, - pub llm: Option>, - pub framework: Option>, - pub meta: Option>, - /// Raw credentials are shared by Arc here too — write tokens for - /// `inbound_tokens` and `delegated_tokens` mutation paths land with - /// the IdentityResolve and TokenDelegate hooks. Until - /// then, no plugin writes through `OwnedExtensions.raw_credentials`. - pub raw_credentials: Option>, - - // Mutable/monotonic/guarded — owned, modifiable - pub http: Option>, - pub security: Option, - pub candidate_constraint: Option, - pub delegation: Option, - pub custom: Option>, - - pub http_write_token: Option, - pub labels_write_token: Option, - pub delegation_write_token: Option, -} -#[cfg(test)] -mod tests { - use super::*; - use crate::extensions::security::SubjectExtension; - use crate::extensions::{ - DelegationExtension, HttpExtension, RequestExtension, SecurityExtension, - }; - - fn make_extensions() -> Extensions { - let mut security = SecurityExtension::default(); - security.add_label("PII"); - - let mut http = HttpExtension::default(); - http.set_header("Authorization", "Bearer token"); - - Extensions { - request: Some(Arc::new(RequestExtension { - request_id: Some("req-001".into()), - ..Default::default() - })), - security: Some(Arc::new(security)), - http: Some(Arc::new(http)), - delegation: Some(Arc::new(DelegationExtension::default())), - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".into()), - ..Default::default() - })), - ..Default::default() - } - } - - #[test] - fn test_cow_copy_shares_immutable_arcs() { - let ext = make_extensions(); - let cow = ext.cow_copy(); - - // Immutable slots share the same Arc — zero copy - assert!(Arc::ptr_eq( - ext.request.as_ref().unwrap(), - cow.request.as_ref().unwrap() - )); - assert!(Arc::ptr_eq( - ext.meta.as_ref().unwrap(), - cow.meta.as_ref().unwrap() - )); - } - - #[test] - fn test_cow_copy_deep_clones_mutable_slots() { - let ext = make_extensions(); - let cow = ext.cow_copy(); - - // Mutable/monotonic slots are deep cloned — independent copies - assert!(cow.security.is_some()); - assert!(cow.http.is_some()); - assert!(cow.delegation.is_some()); - - // Modifying the COW copy doesn't affect the original - cow.security.as_ref().unwrap().has_label("PII"); - } - - #[test] - fn test_cow_copy_propagates_write_tokens() { - let mut ext = make_extensions(); - - // No tokens on the original → no tokens on COW - let cow_no_tokens = ext.cow_copy(); - assert!(cow_no_tokens.http_write_token.is_none()); - assert!(cow_no_tokens.labels_write_token.is_none()); - assert!(cow_no_tokens.delegation_write_token.is_none()); - - // Executor sets tokens based on capabilities - ext.http_write_token = Some(WriteToken::new()); - ext.labels_write_token = Some(WriteToken::new()); - - // COW copy propagates only the tokens that exist - let cow_with_tokens = ext.cow_copy(); - assert!(cow_with_tokens.http_write_token.is_some()); - assert!(cow_with_tokens.labels_write_token.is_some()); - assert!(cow_with_tokens.delegation_write_token.is_none()); // wasn't set - } - - #[test] - fn test_cow_copy_write_token_enables_guarded_write() { - let mut ext = make_extensions(); - ext.http_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - - // Can read without token - assert_eq!( - cow.http - .as_ref() - .unwrap() - .read() - .get_header("Authorization"), - Some("Bearer token") - ); - - // Can write with token from COW - let token = cow.http_write_token.as_ref().unwrap(); - cow.http - .as_mut() - .unwrap() - .write(token) - .set_header("X-Custom", "value"); - - assert_eq!( - cow.http.as_ref().unwrap().read().get_header("X-Custom"), - Some("value") - ); - - // Original unchanged - assert!(ext.http.as_ref().unwrap().get_header("X-Custom").is_none()); - } - - #[test] - fn test_cow_copy_monotonic_label_insert() { - let mut ext = make_extensions(); - ext.labels_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - - // Can add labels on the COW copy - cow.security.as_mut().unwrap().add_label("HIPAA"); - assert!(cow.security.as_ref().unwrap().has_label("HIPAA")); - - // Original unchanged - assert!(!ext.security.as_ref().unwrap().has_label("HIPAA")); - } - - #[test] - fn test_validate_immutable_passes_for_cow() { - let ext = make_extensions(); - let cow = ext.cow_copy(); - - // COW copy shares immutable Arcs → validation passes - assert!(ext.validate_immutable(&cow)); - } - - #[test] - fn test_validate_immutable_fails_when_tampered() { - let ext = make_extensions(); - let mut cow = ext.cow_copy(); - - // Tamper with an immutable slot - cow.request = Some(Arc::new(RequestExtension { - request_id: Some("TAMPERED".into()), - ..Default::default() - })); - - // Validation fails — different Arc pointer - assert!(!ext.validate_immutable(&cow)); - } - - #[test] - fn test_validate_immutable_both_none_passes() { - let ext = Extensions::default(); - let cow = ext.cow_copy(); - assert!(ext.validate_immutable(&cow)); - } - - // ----- candidate_constraint write authority ----- - - fn a_constraint(region: &str) -> CandidateConstraintExtension { - CandidateConstraintExtension { - allow_regions: Some(vec![region.to_string()]), - ..Default::default() - } - } - - fn caps(list: &[&str]) -> HashSet { - list.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn candidate_constraint_unchanged_passes_without_cap() { - // A plugin that leaves the slot at its original value is fine even - // without the write cap — the common case for every plugin. - let mut ext = Extensions::default(); - ext.candidate_constraint = Some(Arc::new(a_constraint("eu"))); - let owned = ext.cow_copy(); // carries the same constraint value - assert!(ext.candidate_constraint_write_ok(&owned, &caps(&[]))); - } - - #[test] - fn candidate_constraint_overwrite_rejected_without_cap() { - let mut ext = Extensions::default(); - ext.candidate_constraint = Some(Arc::new(a_constraint("eu"))); - let mut owned = ext.cow_copy(); - owned.candidate_constraint = Some(a_constraint("us")); // weakened/changed - assert!(!ext.candidate_constraint_write_ok(&owned, &caps(&[]))); - // ...but a cap-holder (the APL engine) may overwrite. - assert!(ext.candidate_constraint_write_ok(&owned, &caps(&[CAP_WRITE_CANDIDATE_CONSTRAINT]))); - } - - #[test] - fn candidate_constraint_removal_rejected_without_cap() { - let mut ext = Extensions::default(); - ext.candidate_constraint = Some(Arc::new(a_constraint("eu"))); - let mut owned = ext.cow_copy(); - owned.candidate_constraint = None; // dropped the policy engine's constraint - assert!(!ext.candidate_constraint_write_ok(&owned, &caps(&[]))); - } - - #[test] - fn candidate_constraint_fabricate_rejected_without_cap() { - let ext = Extensions::default(); // no constraint originally - let mut owned = ext.cow_copy(); - owned.candidate_constraint = Some(a_constraint("eu")); // forged - assert!(!ext.candidate_constraint_write_ok(&owned, &caps(&[]))); - // The APL engine, holding the cap, legitimately creates it. - assert!(ext.candidate_constraint_write_ok(&owned, &caps(&[CAP_WRITE_CANDIDATE_CONSTRAINT]))); - } - - #[test] - fn candidate_constraint_both_none_passes_without_cap() { - let ext = Extensions::default(); - let owned = ext.cow_copy(); - assert!(ext.candidate_constraint_write_ok(&owned, &caps(&[]))); - } - - #[test] - fn test_clone_drops_write_tokens() { - let mut ext = make_extensions(); - ext.http_write_token = Some(WriteToken::new()); - ext.labels_write_token = Some(WriteToken::new()); - ext.delegation_write_token = Some(WriteToken::new()); - - // Regular clone drops all tokens - let cloned = ext.clone(); - assert!(cloned.http_write_token.is_none()); - assert!(cloned.labels_write_token.is_none()); - assert!(cloned.delegation_write_token.is_none()); - - // cow_copy propagates them - let cow = ext.cow_copy(); - assert!(cow.http_write_token.is_some()); - assert!(cow.labels_write_token.is_some()); - assert!(cow.delegation_write_token.is_some()); - } - - #[test] - fn test_cow_copy_modify_multiple_fields() { - use crate::extensions::delegation::DelegationHop; - use crate::extensions::DelegationExtension; - - let mut security = SecurityExtension::default(); - security.add_label("PII"); - - let mut http = HttpExtension::default(); - http.set_header("Authorization", "Bearer token"); - - let mut ext = Extensions { - security: Some(Arc::new(security)), - http: Some(Arc::new(http)), - delegation: Some(Arc::new(DelegationExtension::default())), - custom: Some(Arc::new( - [("existing".to_string(), serde_json::json!("value"))].into(), - )), - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".into()), - ..Default::default() - })), - ..Default::default() - }; - - // Executor sets all write tokens - ext.http_write_token = Some(WriteToken::new()); - ext.labels_write_token = Some(WriteToken::new()); - ext.delegation_write_token = Some(WriteToken::new()); - - // Plugin does one cow_copy, modifies multiple fields - let mut cow = ext.cow_copy(); - - // 1. Add security labels (monotonic) - cow.security.as_mut().unwrap().add_label("CHECKED"); - cow.security.as_mut().unwrap().add_label("COMPLIANT"); - - // 2. Inject HTTP headers (guarded) - let token = cow.http_write_token.as_ref().unwrap(); - cow.http - .as_mut() - .unwrap() - .write(token) - .set_header("X-Checked", "true"); - cow.http - .as_mut() - .unwrap() - .write(token) - .set_header("X-Policy", "v2"); - - // 3. Append delegation hop (monotonic) - cow.delegation.as_mut().unwrap().append_hop(DelegationHop { - subject_id: "service-a".into(), - scopes_granted: vec!["read_hr".into()], - ..Default::default() - }); - - // 4. Add custom data (mutable, no token needed) - cow.custom - .as_mut() - .unwrap() - .insert("audit.timestamp".into(), serde_json::json!("2026-04-29")); - - // Verify COW copy has all modifications - let sec = cow.security.as_ref().unwrap(); - assert!(sec.has_label("PII")); // original - assert!(sec.has_label("CHECKED")); // added - assert!(sec.has_label("COMPLIANT")); // added - - let http = cow.http.as_ref().unwrap().read(); - assert_eq!(http.get_header("Authorization"), Some("Bearer token")); // original - assert_eq!(http.get_header("X-Checked"), Some("true")); // added - assert_eq!(http.get_header("X-Policy"), Some("v2")); // added - - assert_eq!(cow.delegation.as_ref().unwrap().chain.len(), 1); - assert_eq!( - cow.delegation.as_ref().unwrap().chain[0].subject_id, - "service-a" - ); - - assert_eq!( - cow.custom.as_ref().unwrap().get("existing").unwrap(), - "value" - ); - assert_eq!( - cow.custom.as_ref().unwrap().get("audit.timestamp").unwrap(), - "2026-04-29" - ); - - // Verify original is unchanged - assert!(!ext.security.as_ref().unwrap().has_label("CHECKED")); - assert!(ext.http.as_ref().unwrap().get_header("X-Checked").is_none()); - assert!(ext.delegation.as_ref().unwrap().chain.is_empty()); - assert!(!ext.custom.as_ref().unwrap().contains_key("audit.timestamp")); - - // Immutable slots still valid - assert!(ext.validate_immutable(&cow)); - } - - #[test] - fn test_validate_immutable_passes_when_slot_filtered_out() { - // Bug fix regression: when capability filtering hides a slot - // from the plugin (e.g., agent=None in owned because plugin - // lacks read_agent), validate_immutable must NOT treat that - // as tampering. - let ext = make_extensions(); - let mut cow = ext.cow_copy(); - - // Simulate capability filtering hiding the agent slot - cow.agent = None; - - // Validation should pass — plugin never saw the slot - assert!(ext.validate_immutable(&cow)); - } - - #[test] - fn test_validate_immutable_fails_when_slot_fabricated() { - // If the original has no agent but the plugin returns one, - // that's fabrication — should fail. - let ext = Extensions::default(); // no agent - let mut cow = ext.cow_copy(); - - cow.agent = Some(Arc::new(AgentExtension { - agent_id: Some("fabricated".into()), - ..Default::default() - })); - - assert!(!ext.validate_immutable(&cow)); - } - - #[test] - fn test_validate_immutable_passes_multiple_slots_filtered() { - // Multiple immutable slots filtered out — all should pass - let ext = make_extensions(); - let mut cow = ext.cow_copy(); - - cow.agent = None; - cow.mcp = None; - cow.completion = None; - cow.framework = None; - - assert!(ext.validate_immutable(&cow)); - } - - #[test] - fn test_merge_owned_preserves_http_response_headers() { - // Bug fix regression: merge_owned must preserve response - // headers written by a plugin through Guarded write access. - let mut http = HttpExtension::default(); - http.set_request_header("Authorization", "Bearer tok"); - - let mut ext = Extensions { - http: Some(Arc::new(http)), - ..Default::default() - }; - ext.http_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - - // Plugin writes response headers through the guard - let token = cow.http_write_token.as_ref().unwrap(); - let h = cow.http.as_mut().unwrap().write(token); - h.set_response_header("X-Tool-Name", "get_compensation"); - h.set_response_header("X-Status", "success"); - - // Merge back - ext.merge_owned(cow); - - // Response headers must be present after merge - let merged_http = ext.http.as_ref().unwrap(); - assert_eq!( - merged_http.get_response_header("X-Tool-Name"), - Some("get_compensation") - ); - assert_eq!(merged_http.get_response_header("X-Status"), Some("success")); - // Original request headers preserved - assert_eq!( - merged_http.get_request_header("Authorization"), - Some("Bearer tok") - ); - } - - #[test] - fn test_merge_owned_with_filtered_security_preserves_labels() { - // A plugin without read_labels gets empty labels in its filtered - // view. merge_owned must not write that blank over canonical state. - // - // This asserted the opposite before the per-field merge landed — it - // encoded the slot-swap bug as expected behavior. - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.add_label("HR"); - - let mut ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - // Even *with* the append capability, an empty returned set must not - // subtract. Without the token it would not merge at all, which would - // make the test pass for the wrong reason. - ext.labels_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - // Simulate the filtered view: no read_labels → empty label set. - cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::new(); - - ext.merge_owned(cow); - - let merged_sec = ext.security.as_ref().unwrap(); - assert!( - merged_sec.has_label("PII"), - "a label the plugin could not see must survive the merge" - ); - assert!(merged_sec.has_label("HR")); - } - - #[test] - fn test_merge_owned_none_http_preserves_pipeline() { - // If owned.http is None (plugin had no read_headers capability), - // the canonical slot must stand — there is no edit to apply. - let mut http = HttpExtension::default(); - http.set_request_header("X-Original", "value"); - - let mut ext = Extensions { - http: Some(Arc::new(http)), - ..Default::default() - }; - ext.http_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - cow.http = None; // simulate filtered-out HTTP - - ext.merge_owned(cow); - - assert_eq!( - ext.http - .as_ref() - .and_then(|h| h.get_request_header("X-Original")), - Some("value"), - "an absent slot in the return is not an instruction to clear it" - ); - } - - // -- Per-field merge gating (review finding A) -- - // - // Each test below fails against the previous whole-slot `merge_owned`, - // which assigned `self.security = owned.security` (and likewise for http - // and delegation) regardless of which token `owned` carried. - - #[test] - fn test_custom_write_cannot_wipe_security_labels() { - // The headline finding: a plugin with NO security capability returns a - // `custom` value. Its filtered view has empty labels, and the old slot - // swap took that view along — wiping the pipeline's labels via a slot - // the plugin was never gated on. - let mut security = SecurityExtension::default(); - security.add_label("PII"); - - let mut ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - // No tokens at all — this plugin declared no security capability. - - let mut cow = ext.cow_copy(); - cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::new(); - cow.custom = Some([("verdict".to_string(), serde_json::json!("clean"))].into()); - - ext.merge_owned(cow); - - assert!( - ext.security.as_ref().unwrap().has_label("PII"), - "a capability-less custom write must not wipe security labels" - ); - assert_eq!( - ext.custom.as_ref().unwrap().get("verdict").unwrap(), - "clean", - "the legitimate custom write still lands" - ); - } - - #[test] - fn test_labels_token_cannot_rewrite_subject_or_auth_method() { - // `append_labels` is Monotonic on *labels*. `subject` and `auth_method` - // are Immutable with `write_cap: None`, so a labels token must not - // reach them — otherwise appending a label buys a plugin the ability to - // rewrite the principal it authenticated as. - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.subject = Some(SubjectExtension { - id: Some("real-user".into()), - ..Default::default() - }); - security.auth_method = Some("mtls".into()); - - let mut ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - ext.labels_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - let owned_sec = cow.security.as_mut().unwrap(); - owned_sec.add_label("SCANNED"); // legitimate - owned_sec.subject = Some(SubjectExtension { - id: Some("attacker".into()), - ..Default::default() - }); - owned_sec.auth_method = Some("anonymous".into()); - - ext.merge_owned(cow); - - let merged = ext.security.as_ref().unwrap(); - assert!(merged.has_label("SCANNED"), "the label append is honored"); - assert!(merged.has_label("PII"), "and does not subtract"); - assert_eq!( - merged.subject.as_ref().unwrap().id.as_deref(), - Some("real-user"), - "a labels token must not rewrite the authenticated subject" - ); - assert_eq!( - merged.auth_method.as_deref(), - Some("mtls"), - "nor the auth method" - ); - } - - #[test] - fn test_label_removal_is_dropped_whole() { - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.add_label("HIPAA"); - - let mut ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - ext.labels_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - // Drop HIPAA and add something — a laundered declassification. - let mut shrunk = std::collections::HashSet::new(); - shrunk.insert("PII".to_string()); - shrunk.insert("CLEAN".to_string()); - cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::from_set(shrunk); - - ext.merge_owned(cow); - - let merged = ext.security.as_ref().unwrap(); - assert!(merged.has_label("HIPAA"), "the removal is refused"); - assert!( - !merged.has_label("CLEAN"), - "and the edit is dropped whole, not partially applied" - ); - } - - #[test] - fn test_security_merge_needs_the_labels_token() { - let mut security = SecurityExtension::default(); - security.add_label("PII"); - - let mut ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - // No labels token. - - let mut cow = ext.cow_copy(); - cow.security.as_mut().unwrap().add_label("FORGED"); - - ext.merge_owned(cow); - - assert!( - !ext.security.as_ref().unwrap().has_label("FORGED"), - "an append without the capability is dropped" - ); - } - - #[test] - fn test_http_merge_preserves_request_line() { - // Policies gate on method/path/host/scheme (CHANGELOG 0.2.2), and - // `write_headers` does not authorize rewriting request identity. - let mut http = HttpExtension::default(); - http.method = Some("POST".into()); - http.path = Some("/api/v1/transfer".into()); - http.host = Some("bank.internal".into()); - http.scheme = Some("https".into()); - - let mut ext = Extensions { - http: Some(Arc::new(http)), - ..Default::default() - }; - ext.http_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - let token = cow.http_write_token.take().expect("token propagated"); - { - let h = cow.http.as_mut().unwrap().write(&token); - h.set_request_header("X-Scanned", "1"); - // A worker round trip loses these; a hostile one rewrites them. - h.method = Some("GET".into()); - h.path = Some("/api/v1/healthz".into()); - h.host = Some("evil.example".into()); - h.scheme = None; - } - cow.http_write_token = Some(token); - - ext.merge_owned(cow); - - let merged = ext.http.as_ref().unwrap(); - assert_eq!(merged.method.as_deref(), Some("POST")); - 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.get_request_header("X-Scanned"), - Some("1"), - "the header write it was authorized for still lands" - ); - } - - #[test] - fn test_delegation_chain_cannot_be_shortened_or_rewritten() { - use crate::extensions::delegation::DelegationHop; - - let mut delegation = DelegationExtension::default(); - delegation.append_hop(DelegationHop { - subject_id: "user-1".into(), - scopes_granted: vec!["read_hr".into()], - ..Default::default() - }); - delegation.append_hop(DelegationHop { - subject_id: "svc-a".into(), - scopes_granted: vec!["read_hr".into()], - ..Default::default() - }); - delegation.origin_subject_id = Some("user-1".into()); - - let mut ext = Extensions { - delegation: Some(Arc::new(delegation)), - ..Default::default() - }; - ext.delegation_write_token = Some(WriteToken::new()); - - // Truncate to one hop, dropping the narrowing that hop recorded. - let mut cow = ext.cow_copy(); - let owned_del = cow.delegation.as_mut().unwrap(); - owned_del.chain.truncate(1); - owned_del.depth = 1; - - ext.merge_owned(cow); - assert_eq!( - ext.delegation.as_ref().unwrap().chain.len(), - 2, - "a truncated chain is not an append — refused whole" - ); - - // Rewriting the scopes on an existing hop is likewise not an append. - let mut cow = ext.cow_copy(); - cow.delegation.as_mut().unwrap().chain[0].scopes_granted = vec!["admin".into()]; - - ext.merge_owned(cow); - assert_eq!( - ext.delegation.as_ref().unwrap().chain[0].scopes_granted, - vec!["read_hr".to_string()], - "an existing hop's granted scopes cannot be widened" - ); - } - - #[test] - fn test_delegation_append_is_honored_and_depth_recomputed() { - use crate::extensions::delegation::DelegationHop; - - let mut delegation = DelegationExtension::default(); - delegation.append_hop(DelegationHop { - subject_id: "user-1".into(), - scopes_granted: vec!["read_hr".into()], - ..Default::default() - }); - delegation.origin_subject_id = Some("user-1".into()); - - let mut ext = Extensions { - delegation: Some(Arc::new(delegation)), - ..Default::default() - }; - ext.delegation_write_token = Some(WriteToken::new()); - - let mut cow = ext.cow_copy(); - let owned_del = cow.delegation.as_mut().unwrap(); - owned_del.chain.push(DelegationHop { - subject_id: "svc-b".into(), - scopes_granted: vec!["read_hr".into()], - ..Default::default() - }); - // Claim a depth the chain does not have, and try to re-point the root. - owned_del.depth = 99; - owned_del.origin_subject_id = Some("attacker".into()); - - ext.merge_owned(cow); - - let merged = ext.delegation.as_ref().unwrap(); - assert_eq!(merged.chain.len(), 2, "the append is honored"); - assert_eq!(merged.depth, 2, "depth is recomputed, not trusted"); - assert!(merged.delegated); - assert_eq!( - merged.origin_subject_id.as_deref(), - Some("user-1"), - "the chain root cannot be re-pointed once established" - ); - } - - #[test] - fn test_delegation_merge_needs_the_token() { - use crate::extensions::delegation::DelegationHop; - - let mut ext = Extensions { - delegation: Some(Arc::new(DelegationExtension::default())), - ..Default::default() - }; - // No delegation token. - - let mut cow = ext.cow_copy(); - cow.delegation.as_mut().unwrap().append_hop(DelegationHop { - subject_id: "forged".into(), - ..Default::default() - }); - - ext.merge_owned(cow); - - assert!( - ext.delegation.as_ref().unwrap().chain.is_empty(), - "appending a hop without the capability is dropped" - ); - } - - #[test] - fn test_read_only_plugin_zero_cost() { - // Plugin that only reads — no cow_copy, no clone - let ext = make_extensions(); - - // Read security labels - let has_pii = ext - .security - .as_ref() - .map(|s| s.has_label("PII")) - .unwrap_or(false); - assert!(has_pii); - - // Read HTTP headers - let auth = ext - .http - .as_ref() - .and_then(|h| h.get_header("Authorization")); - assert_eq!(auth, Some("Bearer token")); - - // Read meta - let entity = ext.meta.as_ref().and_then(|m| m.entity_type.as_deref()); - assert_eq!(entity, Some("tool")); - - // No cow_copy called — zero allocations for read-only access - } -} diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs deleted file mode 100644 index 2f5f9f05..00000000 --- a/crates/cpex-core/src/extensions/delegation.rs +++ /dev/null @@ -1,218 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/delegation.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// DelegationExtension — token delegation chain. -// Mirrors cpex/framework/extensions/delegation.py. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use super::authorization::AuthorizationDetail; -use super::security::SubjectType; - -/// Delegation strategy used to mint the credential at this hop. -/// -/// The known variants cover the reference implementations. -/// `Custom(String)` is the -/// escape hatch for host-defined strategies (UCAN variants, in-house mints). -/// Marked `#[non_exhaustive]` so new known variants can be added without a -/// breaking change to host code that exhaustively matches. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum DelegationStrategy { - TokenExchange, - ClientCredentials, - SpiffeSvid, - Passthrough, - Ucan, - TransactionToken, - #[serde(untagged)] - Custom(String), -} - -/// A single hop in the delegation chain. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DelegationHop { - /// Subject ID of the delegator. - pub subject_id: String, - - /// Subject type of the delegator. Reuses the typed `SubjectType` - /// enum from `SecurityExtension.subject`, not a freeform string. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_type: Option, - - /// Target audience. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub audience: Option, - - /// Scopes granted in this delegation step. - #[serde(default)] - pub scopes_granted: Vec, - - /// RFC 9396 authorization_details carried alongside scopes. - /// Each hop's details must be structurally narrowed from the previous. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub authorization_details: Vec, - - /// When this hop was minted. Default is the Unix epoch — production - /// code constructs with `Utc::now()`; only tests rely on the default. - #[serde(default)] - pub timestamp: DateTime, - - /// Time-to-live in seconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ttl_seconds: Option, - - /// Delegation strategy used. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, - - /// Whether this hop was resolved from cache. - #[serde(default)] - pub from_cache: bool, -} - -/// Delegation chain extension. -/// -/// Append-only — each hop narrows scope. A delegate cannot have -/// more permissions than the delegator. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DelegationExtension { - /// Ordered delegation chain. - #[serde(default)] - pub chain: Vec, - - /// Chain depth (number of hops). `u32` for wire-stable width. - #[serde(default)] - pub depth: u32, - - /// Subject ID of the original delegator. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub origin_subject_id: Option, - - /// Subject ID of the current actor. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actor_subject_id: Option, - - /// Whether delegation has occurred. - #[serde(default)] - pub delegated: bool, - - /// Age of the delegation chain in seconds. - #[serde(default)] - pub age_seconds: f64, -} - -impl DelegationExtension { - /// Append a delegation hop (monotonic — cannot remove). - pub fn append_hop(&mut self, hop: DelegationHop) { - self.chain.push(hop); - // Cast is safe: a chain with > u32::MAX hops would have failed - // memory allocation long ago. - self.depth = self.chain.len() as u32; - self.delegated = true; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_delegation_starts_empty() { - let del = DelegationExtension::default(); - assert!(del.chain.is_empty()); - assert_eq!(del.depth, 0); - assert!(!del.delegated); - } - - #[test] - fn test_append_hop() { - let mut del = DelegationExtension::default(); - del.append_hop(DelegationHop { - subject_id: "alice".into(), - scopes_granted: vec!["read_hr".into()], - ..Default::default() - }); - - assert_eq!(del.chain.len(), 1); - assert_eq!(del.depth, 1); - assert!(del.delegated); - assert_eq!(del.chain[0].subject_id, "alice"); - assert_eq!(del.chain[0].scopes_granted, vec!["read_hr"]); - } - - #[test] - fn test_append_multiple_hops() { - let mut del = DelegationExtension { - origin_subject_id: Some("alice".into()), - ..Default::default() - }; - - del.append_hop(DelegationHop { - subject_id: "alice".into(), - audience: Some("service-b".into()), - scopes_granted: vec!["read".into(), "write".into()], - strategy: Some(DelegationStrategy::TokenExchange), - ..Default::default() - }); - - del.append_hop(DelegationHop { - subject_id: "service-b".into(), - audience: Some("service-c".into()), - scopes_granted: vec!["read".into()], // narrowed scope - ..Default::default() - }); - - assert_eq!(del.chain.len(), 2); - assert_eq!(del.depth, 2); - // Second hop has narrower scope - assert_eq!(del.chain[1].scopes_granted, vec!["read"]); - } - - #[test] - fn test_strategy_serde_known_and_custom() { - // Known variant serializes as snake_case string. - let known = DelegationStrategy::TokenExchange; - let json = serde_json::to_string(&known).unwrap(); - assert_eq!(json, "\"token_exchange\""); - let back: DelegationStrategy = serde_json::from_str(&json).unwrap(); - assert_eq!(back, DelegationStrategy::TokenExchange); - - // Custom variant serializes as a bare string (untagged). - let custom = DelegationStrategy::Custom("in_house_mint".into()); - let json = serde_json::to_string(&custom).unwrap(); - assert_eq!(json, "\"in_house_mint\""); - // Deserializing a string that doesn't match a known variant falls - // through to Custom — the escape hatch. - let back: DelegationStrategy = serde_json::from_str("\"in_house_mint\"").unwrap(); - assert_eq!(back, DelegationStrategy::Custom("in_house_mint".into())); - } - - #[test] - fn test_delegation_serde_roundtrip() { - let mut del = DelegationExtension { - origin_subject_id: Some("alice".into()), - actor_subject_id: Some("service-b".into()), - ..Default::default() - }; - del.append_hop(DelegationHop { - subject_id: "alice".into(), - subject_type: Some(SubjectType::User), - scopes_granted: vec!["admin".into()], - from_cache: true, - ..Default::default() - }); - - let json = serde_json::to_string(&del).unwrap(); - let deserialized: DelegationExtension = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.depth, 1); - assert!(deserialized.delegated); - assert_eq!(deserialized.origin_subject_id.as_deref(), Some("alice")); - assert!(deserialized.chain[0].from_cache); - } -} diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs deleted file mode 100644 index 82ac858d..00000000 --- a/crates/cpex-core/src/extensions/filter.rs +++ /dev/null @@ -1,857 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/filter.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Extension filtering — capability-gated visibility. -// -// Builds a Extensions from Extensions + declared capabilities. -// Secure by default: slots not explicitly included are None. -// -// Mirrors cpex/framework/extensions/tiers.py::filter_extensions(). - -use std::collections::HashSet; -use std::sync::Arc; - -use super::container::Extensions; - -use super::security::{SecurityExtension, SubjectExtension}; -use super::tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; - -/// Extension slot identifiers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SlotName { - Request, - Agent, - Http, - Meta, - Delegation, - Custom, - Mcp, - Completion, - Provenance, - Llm, - Framework, - // Security sub-slots - SecurityLabels, - SecuritySubject, - SecuritySubjectRoles, - SecuritySubjectTeams, - SecuritySubjectClaims, - SecuritySubjectPermissions, - SecurityClient, - SecurityCallerWorkload, - SecurityThisWorkload, - SecurityObjects, - SecurityData, - // Raw credentials sub-slots (Layer 3 — capability-gated). Token - // fields are `#[serde(skip)]`, so a filtered view that survives to - // an out-of-process plugin over the generic `extensions` channel - // carries metadata with empty token strings. Plaintext reaches a - // worker only over a host's purpose-built side channel, under the - // conditions documented on `RawCredentialsExtension`. - RawCredentialsInbound, - RawCredentialsDelegated, -} - -/// Get the policy for a given slot. -pub fn slot_policy(slot: SlotName) -> SlotPolicy { - match slot { - // Unrestricted immutable — always visible - SlotName::Request => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Provenance => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Completion => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Llm => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Framework => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Mcp => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Meta => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::Custom => SlotPolicy { - tier: MutabilityTier::Mutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - // Capability-gated - SlotName::Agent => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadAgent), - write_cap: None, - }, - SlotName::Http => SlotPolicy { - tier: MutabilityTier::Mutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadHeaders), - write_cap: Some(Capability::WriteHeaders), - }, - SlotName::Delegation => SlotPolicy { - tier: MutabilityTier::Monotonic, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadDelegation), - write_cap: Some(Capability::AppendDelegation), - }, - // Security sub-slots - SlotName::SecurityLabels => SlotPolicy { - tier: MutabilityTier::Monotonic, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadLabels), - write_cap: Some(Capability::AppendLabels), - }, - SlotName::SecuritySubject => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadSubject), - write_cap: None, - }, - SlotName::SecuritySubjectRoles => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadRoles), - write_cap: None, - }, - SlotName::SecuritySubjectTeams => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadTeams), - write_cap: None, - }, - SlotName::SecuritySubjectClaims => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadClaims), - write_cap: None, - }, - SlotName::SecuritySubjectPermissions => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadPermissions), - write_cap: None, - }, - SlotName::SecurityObjects => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - SlotName::SecurityData => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::Unrestricted, - read_cap: None, - write_cap: None, - }, - // Identity slots populated by IdentityResolve handlers. Read - // gated; write is None because the framework — not plugins — - // mutates these slots in response to handler-returned - // `IdentityResult` payloads (see `Capability` docstring). - SlotName::SecurityClient => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadClient), - write_cap: None, - }, - SlotName::SecurityCallerWorkload => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadWorkload), - write_cap: None, - }, - SlotName::SecurityThisWorkload => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadWorkload), - write_cap: None, - }, - // Layer-3 raw credentials. Granular gating so a forwarding - // plugin that only needs delegated tokens never sees inbound - // bearer material, and an identity-resolver that only needs - // inbound tokens never sees the cached delegated set. - SlotName::RawCredentialsInbound => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadInboundCredentials), - write_cap: None, - }, - SlotName::RawCredentialsDelegated => SlotPolicy { - tier: MutabilityTier::Immutable, - access: AccessPolicy::CapabilityGated, - read_cap: Some(Capability::ReadDelegatedTokens), - write_cap: None, - }, - } -} - -/// Check if a set of capabilities grants read access to a slot. -fn has_read_access(policy: &SlotPolicy, capabilities: &HashSet) -> bool { - if policy.access == AccessPolicy::Unrestricted { - return true; - } - if let Some(read_cap) = &policy.read_cap { - let cap_str = serde_json::to_string(read_cap) - .unwrap_or_default() - .trim_matches('"') - .to_string(); - if capabilities.contains(&cap_str) { - return true; - } - } - // Check if any subject sub-field cap implies read_subject - if policy.read_cap == Some(Capability::ReadSubject) { - return has_any_subject_capability(capabilities); - } - false -} - -/// Check if capabilities include any subject-related capability. -fn has_any_subject_capability(capabilities: &HashSet) -> bool { - let subject_caps = [ - Capability::ReadSubject, - Capability::ReadRoles, - Capability::ReadTeams, - Capability::ReadClaims, - Capability::ReadPermissions, - ]; - for cap in &subject_caps { - let cap_str = serde_json::to_string(cap) - .unwrap_or_default() - .trim_matches('"') - .to_string(); - if capabilities.contains(&cap_str) { - return true; - } - } - false -} - -/// Helper: convert Capability to its string representation. -fn cap_str(cap: Capability) -> String { - serde_json::to_string(&cap) - .unwrap_or_default() - .trim_matches('"') - .to_string() -} - -/// Build a Extensions containing only slots the plugin can access. -/// -/// Starts from an empty Extensions and clones in only the -/// slots the plugin has read access to. Slots not explicitly included -/// are `None`. Secure by default — if a new slot is added to -/// Extensions but not registered here, it remains hidden. -/// -/// For the security extension, filtering is granular: unrestricted -/// sub-fields (objects, data, classification) are always included, -/// while labels and subject sub-fields are gated by capabilities. -pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet) -> Extensions { - // Build the unrestricted-immutable fields up front; capability-gated - // slots stay default and are filled in below. - let mut filtered = Extensions { - request: extensions.request.clone(), - provenance: extensions.provenance.clone(), - completion: extensions.completion.clone(), - llm: extensions.llm.clone(), - framework: extensions.framework.clone(), - mcp: extensions.mcp.clone(), - meta: extensions.meta.clone(), - custom: extensions.custom.clone(), - // Pass through like `custom` (ungated in v1): the APL engine - // writes this output slot, and passing it through keeps a later - // plugin's `merge_owned` from clobbering it back to `None`. - // Capability-gating the write is future work. - candidate_constraint: extensions.candidate_constraint.clone(), - ..Default::default() - }; - - // Capability-gated: delegation - if extensions.delegation.is_some() { - let policy = slot_policy(SlotName::Delegation); - if has_read_access(&policy, capabilities) { - filtered.delegation = extensions.delegation.clone(); - } - } - - // Capability-gated: agent - if extensions.agent.is_some() { - let policy = slot_policy(SlotName::Agent); - if has_read_access(&policy, capabilities) { - filtered.agent = extensions.agent.clone(); - } - } - - // Capability-gated: http - if extensions.http.is_some() { - let policy = slot_policy(SlotName::Http); - if has_read_access(&policy, capabilities) { - filtered.http = extensions.http.clone(); - } - } - - // Security — granular sub-field filtering - if let Some(ref security) = extensions.security { - filtered.security = Some(Arc::new(build_filtered_security(security, capabilities))); - } - - // Raw credentials — granular sub-map filtering. The slot itself - // appears in the filtered view iff at least one of the two - // sub-caps is held; otherwise the whole slot is `None` so the - // plugin can't even observe that credentials exist. When the - // slot does appear, only the maps whose caps the plugin holds - // are populated; the others are empty. - if let Some(ref raw) = extensions.raw_credentials { - let inbound_policy = slot_policy(SlotName::RawCredentialsInbound); - let delegated_policy = slot_policy(SlotName::RawCredentialsDelegated); - let allow_inbound = has_read_access(&inbound_policy, capabilities); - let allow_delegated = has_read_access(&delegated_policy, capabilities); - if allow_inbound || allow_delegated { - filtered.raw_credentials = Some(Arc::new(build_filtered_raw_credentials( - raw, - allow_inbound, - allow_delegated, - ))); - } - } - - filtered -} - -/// Build a filtered `RawCredentialsExtension` containing only the -/// sub-maps the plugin can read. `inbound_tokens` and -/// `delegated_tokens` are gated independently — a forwarding plugin -/// that only needs to re-attach minted tokens holds -/// `read_delegated_tokens` and never sees inbound bearer material; -/// an identity-resolver holds `read_inbound_credentials` and never -/// sees the cached outbound set. -/// -/// Token *contents* are also stripped at the serde layer -/// (`RawInboundToken.token` / `RawDelegatedToken.token` are -/// `#[serde(skip)]`), so even a serialized snapshot of the filtered -/// extension produces no bearer material. The capability gate is -/// belt-and-suspenders. -fn build_filtered_raw_credentials( - raw: &super::raw_credentials::RawCredentialsExtension, - allow_inbound: bool, - allow_delegated: bool, -) -> super::raw_credentials::RawCredentialsExtension { - super::raw_credentials::RawCredentialsExtension { - inbound_tokens: if allow_inbound { - raw.inbound_tokens.clone() - } else { - Default::default() - }, - delegated_tokens: if allow_delegated { - raw.delegated_tokens.clone() - } else { - Default::default() - }, - } -} - -/// Build a filtered SecurityExtension containing only accessible fields. -/// -/// Unrestricted sub-fields (objects, data, classification) are always -/// included. Labels and subject sub-fields are gated by capabilities. -fn build_filtered_security( - security: &SecurityExtension, - capabilities: &HashSet, -) -> SecurityExtension { - let mut filtered = SecurityExtension { - // Unrestricted — always included - objects: security.objects.clone(), - data: security.data.clone(), - classification: security.classification.clone(), - // `auth_method` is metadata about how the request authenticated - // — useful for audit/branching, never carries credential bytes - // — so it's kept unrestricted. - auth_method: security.auth_method.clone(), - // Default empty / None for capability-gated fields below. - labels: super::MonotonicSet::new(), - subject: None, - client: None, - caller_workload: None, - this_workload: None, - }; - - // Labels — capability-gated - let labels_policy = slot_policy(SlotName::SecurityLabels); - if has_read_access(&labels_policy, capabilities) { - filtered.labels = security.labels.clone(); - } - - // Subject — granular capability-gated. The slot appears iff any - // subject sub-cap is held; individual sub-fields then check - // their own caps in `build_filtered_subject`. - if let Some(ref subject) = security.subject { - if has_any_subject_capability(capabilities) { - filtered.subject = Some(build_filtered_subject(subject, capabilities)); - } - } - - // Client (OAuth application identity) — gated under `read_client`. - // Note: no granular sub-field gating for client at v0 — operators - // hold `read_client` to see the slot or nothing. Granular caps - // can land later if a real use case wants to expose, say, - // `client.authorized_scopes` without `client.claims`. - if let Some(ref client) = security.client { - let client_policy = slot_policy(SlotName::SecurityClient); - if has_read_access(&client_policy, capabilities) { - filtered.client = Some(client.clone()); - } - } - - // Inbound caller's attested workload identity — gated under - // `read_workload`. Same single cap controls both workload slots. - if let Some(ref cw) = security.caller_workload { - let policy = slot_policy(SlotName::SecurityCallerWorkload); - if has_read_access(&policy, capabilities) { - filtered.caller_workload = Some(cw.clone()); - } - } - - // Our own outbound workload identity — also gated under - // `read_workload`. Plugins not declaring it never - // see our gateway's SPIFFE-SVID. - if let Some(ref tw) = security.this_workload { - let policy = slot_policy(SlotName::SecurityThisWorkload); - if has_read_access(&policy, capabilities) { - filtered.this_workload = Some(tw.clone()); - } - } - - filtered -} - -/// Build a filtered SubjectExtension containing only accessible fields. -/// -/// Always includes id and type (base subject access). Individual -/// sub-fields are only populated if the plugin holds the capability. -fn build_filtered_subject( - subject: &SubjectExtension, - capabilities: &HashSet, -) -> SubjectExtension { - SubjectExtension { - // Always included with any subject access - id: subject.id.clone(), - subject_type: subject.subject_type, - // Capability-gated sub-fields - roles: if capabilities.contains(&cap_str(Capability::ReadRoles)) { - subject.roles.clone() - } else { - HashSet::new() - }, - permissions: if capabilities.contains(&cap_str(Capability::ReadPermissions)) { - subject.permissions.clone() - } else { - HashSet::new() - }, - teams: if capabilities.contains(&cap_str(Capability::ReadTeams)) { - subject.teams.clone() - } else { - HashSet::new() - }, - claims: if capabilities.contains(&cap_str(Capability::ReadClaims)) { - subject.claims.clone() - } else { - std::collections::HashMap::new() - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::extensions::meta::MetaExtension; - use crate::extensions::SecurityExtension; - - fn make_full_extensions() -> Extensions { - let mut security = SecurityExtension::default(); - security.add_label("PII"); - security.classification = Some("confidential".into()); - security.subject = Some(SubjectExtension { - id: Some("alice".into()), - subject_type: Some(super::super::security::SubjectType::User), - roles: ["admin".to_string()].into(), - permissions: ["read_all".to_string()].into(), - teams: ["engineering".to_string()].into(), - claims: [("iss".to_string(), "example.com".to_string())].into(), - }); - - let mut http = super::super::HttpExtension::default(); - http.set_header("Authorization", "Bearer token123"); - - Extensions { - request: Some(std::sync::Arc::new(super::super::RequestExtension { - request_id: Some("req-001".into()), - ..Default::default() - })), - security: Some(Arc::new(security)), - http: Some(std::sync::Arc::new(http)), - agent: Some(std::sync::Arc::new(super::super::AgentExtension { - agent_id: Some("agent-1".into()), - ..Default::default() - })), - delegation: Some(std::sync::Arc::new(super::super::DelegationExtension { - delegated: true, - ..Default::default() - })), - meta: Some(std::sync::Arc::new(MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - custom: Some(Arc::new( - [("key".to_string(), serde_json::json!("value"))].into(), - )), - ..Default::default() - } - } - - #[test] - fn test_no_capabilities_sees_unrestricted_only() { - let ext = make_full_extensions(); - let caps = HashSet::new(); - let filtered = filter_extensions(&ext, &caps); - - // Unrestricted slots visible - assert!(filtered.request.is_some()); - assert!(filtered.meta.is_some()); - assert!(filtered.custom.is_some()); - - // Capability-gated slots hidden - assert!(filtered.http.is_none()); - assert!(filtered.agent.is_none()); - assert!(filtered.delegation.is_none()); - - // Security: objects/data/classification visible, labels/subject hidden - let sec = filtered.security.as_ref().unwrap(); - assert!(sec.labels.is_empty()); - assert!(sec.subject.is_none()); - assert_eq!(sec.classification, Some("confidential".into())); - } - - #[test] - fn test_read_headers_capability() { - let ext = make_full_extensions(); - let caps: HashSet = ["read_headers".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - - assert!(filtered.http.is_some()); - assert_eq!( - filtered.http.unwrap().get_header("Authorization"), - Some("Bearer token123") - ); - // Still no agent access - assert!(filtered.agent.is_none()); - } - - #[test] - fn test_read_agent_capability() { - let ext = make_full_extensions(); - let caps: HashSet = ["read_agent".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - - assert!(filtered.agent.is_some()); - assert_eq!(filtered.agent.unwrap().agent_id, Some("agent-1".into())); - assert!(filtered.http.is_none()); - } - - #[test] - fn test_read_labels_capability() { - let ext = make_full_extensions(); - let caps: HashSet = ["read_labels".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - - let sec = filtered.security.as_ref().unwrap(); - assert!(sec.has_label("PII")); - // No subject access — just label access - assert!(sec.subject.is_none()); - } - - #[test] - fn test_read_subject_sees_id_and_type_only() { - let ext = make_full_extensions(); - let caps: HashSet = ["read_subject".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - - let sec = filtered.security.as_ref().unwrap(); - let subject = sec.subject.as_ref().unwrap(); - assert_eq!(subject.id, Some("alice".into())); - // Sub-fields empty without specific capabilities - assert!(subject.roles.is_empty()); - assert!(subject.permissions.is_empty()); - assert!(subject.teams.is_empty()); - assert!(subject.claims.is_empty()); - } - - #[test] - fn test_read_roles_implies_subject_access() { - let ext = make_full_extensions(); - let caps: HashSet = ["read_roles".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - - let sec = filtered.security.as_ref().unwrap(); - let subject = sec.subject.as_ref().unwrap(); - // Has subject access (implied by read_roles) - assert_eq!(subject.id, Some("alice".into())); - // Has roles - assert!(subject.roles.contains("admin")); - // No other sub-fields - assert!(subject.permissions.is_empty()); - assert!(subject.teams.is_empty()); - } - - #[test] - fn test_full_capabilities() { - let ext = make_full_extensions(); - let caps: HashSet = [ - "read_headers", - "read_agent", - "read_delegation", - "read_labels", - "read_subject", - "read_roles", - "read_permissions", - "read_teams", - "read_claims", - ] - .into_iter() - .map(String::from) - .collect(); - - let filtered = filter_extensions(&ext, &caps); - - // Everything visible - assert!(filtered.http.is_some()); - assert!(filtered.agent.is_some()); - assert!(filtered.delegation.is_some()); - - let sec = filtered.security.as_ref().unwrap(); - assert!(sec.has_label("PII")); - let subject = sec.subject.as_ref().unwrap(); - assert!(subject.roles.contains("admin")); - assert!(subject.permissions.contains("read_all")); - assert!(subject.teams.contains("engineering")); - assert!(subject.claims.contains_key("iss")); - } - - #[test] - fn test_read_delegation_capability() { - let ext = make_full_extensions(); - let caps: HashSet = ["read_delegation".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - - assert!(filtered.delegation.is_some()); - assert!(filtered.delegation.unwrap().delegated); - } - - /// Builds a SecurityExtension carrying all four identity principal - /// slots — subject, client, caller_workload, this_workload. - /// Used by the new-slot cap-gating tests. - fn security_with_all_principals() -> SecurityExtension { - use crate::extensions::{ - ClientExtension, ClientTrustLevel, SubjectExtension, WorkloadIdentity, - }; - SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }), - client: Some(ClientExtension { - client_id: "agent-app".into(), - trust_level: ClientTrustLevel::FirstParty, - authorized_scopes: vec!["read".into()], - ..Default::default() - }), - caller_workload: Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/caller".into()), - trust_domain: Some("corp.com".into()), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/gateway".into()), - trust_domain: Some("corp.com".into()), - ..Default::default() - }), - ..Default::default() - } - } - - fn extensions_with_principals() -> Extensions { - Extensions { - security: Some(Arc::new(security_with_all_principals())), - ..Default::default() - } - } - - #[test] - fn no_caps_hides_client_workload_slots() { - // Sanity for the new gating: with empty caps, none of the new - // identity slots should appear post-filter. Subject also stays - // hidden (existing behavior — left in for breadth). - let ext = extensions_with_principals(); - let filtered = filter_extensions(&ext, &HashSet::new()); - let sec = filtered.security.as_ref().unwrap(); - assert!(sec.subject.is_none()); - assert!( - sec.client.is_none(), - "client must be hidden without read_client" - ); - assert!( - sec.caller_workload.is_none(), - "caller_workload must be hidden without read_workload", - ); - assert!( - sec.this_workload.is_none(), - "this_workload must be hidden without read_workload (changed from always-visible in slice 1)", - ); - } - - #[test] - fn read_client_exposes_client_only() { - let ext = extensions_with_principals(); - let caps: HashSet = ["read_client".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - let sec = filtered.security.as_ref().unwrap(); - assert!(sec.client.is_some()); - assert_eq!(sec.client.as_ref().unwrap().client_id, "agent-app"); - // Granting read_client must not leak workload slots. - assert!(sec.caller_workload.is_none()); - assert!(sec.this_workload.is_none()); - } - - #[test] - fn read_workload_exposes_both_workload_slots() { - // One cap controls both inbound (`caller_workload`) and - // outbound (`this_workload`) attested-workload slots. Asserting - // the symmetric behavior is load-bearing for the architectural - // decision; if we ever split them into separate caps this test - // will catch the regression. - let ext = extensions_with_principals(); - let caps: HashSet = ["read_workload".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - let sec = filtered.security.as_ref().unwrap(); - assert!(sec.caller_workload.is_some()); - assert_eq!( - sec.caller_workload.as_ref().unwrap().spiffe_id.as_deref(), - Some("spiffe://corp.com/caller"), - ); - assert!(sec.this_workload.is_some()); - assert_eq!( - sec.this_workload.as_ref().unwrap().spiffe_id.as_deref(), - Some("spiffe://corp.com/gateway"), - ); - // No leak into client. - assert!(sec.client.is_none()); - } - - fn extensions_with_raw_credentials() -> Extensions { - use crate::extensions::raw_credentials::{ - DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, - RawInboundToken, TokenKind, TokenRole, - }; - let mut raw = RawCredentialsExtension::default(); - raw.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new("user-jwt-bytes", "X-User-Token", TokenKind::Jwt), - ); - raw.delegated_tokens.insert( - DelegationKey { - subject_id: "alice".into(), - workload_id: None, - client_id: None, - audience: "https://api.example.com".into(), - scopes: vec!["read".into()], - mode: DelegationMode::OnBehalfOfUser, - }, - RawDelegatedToken::new( - "delegated-bytes", - "Authorization", - "https://api.example.com", - vec!["read".into()], - chrono::Utc::now(), - ), - ); - Extensions { - raw_credentials: Some(Arc::new(raw)), - ..Default::default() - } - } - - #[test] - fn no_raw_credential_caps_hides_slot_entirely() { - // Belt-and-suspenders security story: without either sub-cap, - // the plugin can't even observe that credentials exist. - let ext = extensions_with_raw_credentials(); - let filtered = filter_extensions(&ext, &HashSet::new()); - assert!(filtered.raw_credentials.is_none()); - } - - #[test] - fn read_inbound_credentials_exposes_inbound_only() { - let ext = extensions_with_raw_credentials(); - let caps: HashSet = ["read_inbound_credentials".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - let raw = filtered.raw_credentials.as_ref().unwrap(); - // Inbound visible. - assert_eq!(raw.inbound_tokens.len(), 1); - // Delegated map present but empty — a plugin holding only - // inbound cap must never see minted outbound tokens. - assert!(raw.delegated_tokens.is_empty()); - } - - #[test] - fn read_delegated_tokens_exposes_delegated_only() { - let ext = extensions_with_raw_credentials(); - let caps: HashSet = ["read_delegated_tokens".to_string()].into(); - let filtered = filter_extensions(&ext, &caps); - let raw = filtered.raw_credentials.as_ref().unwrap(); - assert!(raw.inbound_tokens.is_empty()); - assert_eq!(raw.delegated_tokens.len(), 1); - } - - #[test] - fn both_raw_credential_caps_exposes_both_maps() { - let ext = extensions_with_raw_credentials(); - let caps: HashSet = [ - "read_inbound_credentials".to_string(), - "read_delegated_tokens".to_string(), - ] - .into(); - let filtered = filter_extensions(&ext, &caps); - let raw = filtered.raw_credentials.as_ref().unwrap(); - assert_eq!(raw.inbound_tokens.len(), 1); - assert_eq!(raw.delegated_tokens.len(), 1); - } -} diff --git a/crates/cpex-core/src/extensions/framework.rs b/crates/cpex-core/src/extensions/framework.rs deleted file mode 100644 index b4654055..00000000 --- a/crates/cpex-core/src/extensions/framework.rs +++ /dev/null @@ -1,38 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/framework.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// FrameworkExtension — agentic framework context. -// Mirrors cpex/framework/extensions/framework.py. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// Agentic framework context. -/// -/// Carries framework identity and graph/workflow metadata. -/// Immutable — set by the host. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct FrameworkExtension { - /// Framework name (e.g., "langchain", "crewai", "autogen"). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub framework: Option, - - /// Framework version. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub framework_version: Option, - - /// Node ID in an agent graph/workflow. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, - - /// Graph/workflow ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph_id: Option, - - /// Framework-specific metadata. - #[serde(default)] - pub metadata: HashMap, -} diff --git a/crates/cpex-core/src/extensions/guarded.rs b/crates/cpex-core/src/extensions/guarded.rs deleted file mode 100644 index 5b095987..00000000 --- a/crates/cpex-core/src/extensions/guarded.rs +++ /dev/null @@ -1,143 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/guarded.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Guarded — capability-gated write access. -// -// A value that requires a WriteToken for mutable access. Read access -// is always available (if the plugin can see the extension at all). -// Write access requires the framework to issue a WriteToken based on -// the plugin's declared capabilities. -// -// Mirrors the reference implementation spec. - -use serde::{Deserialize, Serialize}; - -/// A value that requires a WriteToken for mutable access. -/// -/// Read access via `.read()` is always available. Write access via -/// `.write(token)` requires a `WriteToken` proving the caller has -/// the capability. -/// -/// The framework issues write tokens only to plugins that declared -/// the corresponding write capability (e.g., `write_headers`). -/// Plugin code without a token cannot call `.write()`. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(transparent)] -pub struct Guarded { - inner: T, -} - -impl Guarded { - /// Wrap a value in a guard. - pub fn new(value: T) -> Self { - Self { inner: value } - } - - /// Read access — always available if the plugin can see this extension. - pub fn read(&self) -> &T { - &self.inner - } - - /// Write access — requires a WriteToken proving the caller has capability. - /// - /// The framework issues WriteTokens only to plugins that declared - /// the write capability in their config. Without the token, this - /// method is uncallable — the plugin can read but not write. - pub fn write(&mut self, _token: &WriteToken) -> &mut T { - &mut self.inner - } - - /// Consume the guard, returning the inner value. - pub fn into_inner(self) -> T { - self.inner - } -} - -impl Default for Guarded { - fn default() -> Self { - Self { - inner: T::default(), - } - } -} - -/// Opaque token for write access — only the framework can create one. -/// -/// `pub(crate)` constructor means plugin crates cannot mint tokens. -/// The executor creates tokens based on the plugin's declared -/// capabilities from `PluginConfig`. -pub struct WriteToken { - _private: (), -} - -impl WriteToken { - /// Only callable by the framework (pub(crate)). - /// Plugin crates cannot construct this. - pub(crate) fn new() -> Self { - Self { _private: () } - } -} - -// WriteToken is not Clone, not Copy — each plugin gets its own from the executor. -// It's also not Send/Sync by default (no auto-traits on zero-sized private fields). -// We explicitly mark it safe since it's just a capability proof with no data. -unsafe impl Send for WriteToken {} -unsafe impl Sync for WriteToken {} - -impl std::fmt::Debug for WriteToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("WriteToken") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_guarded_read_without_token() { - let guarded = Guarded::new(42); - assert_eq!(*guarded.read(), 42); - } - - #[test] - fn test_guarded_write_with_token() { - let mut guarded = Guarded::new(42); - let token = WriteToken::new(); - *guarded.write(&token) = 100; - assert_eq!(*guarded.read(), 100); - } - - #[test] - fn test_guarded_serde_transparent() { - let guarded = Guarded::new("hello".to_string()); - let json = serde_json::to_string(&guarded).unwrap(); - assert_eq!(json, "\"hello\""); - let deserialized: Guarded = serde_json::from_str(&json).unwrap(); - assert_eq!(*deserialized.read(), "hello"); - } - - #[test] - fn test_guarded_with_struct() { - use std::collections::HashMap; - - #[derive(Clone, Debug, Default, Serialize, Deserialize)] - struct Headers { - map: HashMap, - } - - let mut guarded = Guarded::new(Headers::default()); - let token = WriteToken::new(); - - // Read — no token needed - assert!(guarded.read().map.is_empty()); - - guarded - .write(&token) - .map - .insert("X-Auth".into(), "Bearer tok".into()); - assert_eq!(guarded.read().map.get("X-Auth").unwrap(), "Bearer tok"); - } -} diff --git a/crates/cpex-core/src/extensions/http.rs b/crates/cpex-core/src/extensions/http.rs deleted file mode 100644 index 464f0889..00000000 --- a/crates/cpex-core/src/extensions/http.rs +++ /dev/null @@ -1,231 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/http.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// HttpExtension — HTTP request and response headers. -// Mirrors cpex/framework/extensions/http.py. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// HTTP-related extensions. -/// -/// Carries both request and response headers separately. The host -/// populates what's available at each hook point: -/// - Pre-invoke: `request_headers` filled, `response_headers` empty -/// - Post-invoke: both filled (request from original, response from upstream) -/// -/// Capability-gated: requires `read_headers` to see, `write_headers` -/// to modify (both request and response). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct HttpExtension { - /// HTTP request headers (inbound from caller). - #[serde(default)] - pub request_headers: HashMap, - - /// HTTP response headers (from upstream, populated post-invoke). - #[serde(default)] - pub response_headers: HashMap, - - /// HTTP request method (e.g. `GET`, `POST`). Set by the host when - /// the request is HTTP; `None` for non-HTTP transports. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub method: Option, - - /// HTTP request path (e.g. `/api/v1/widgets`). Excludes the query - /// string unless the host chooses to include it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - - /// HTTP request authority/host. The host MUST populate this from a - /// validated authority (e.g. the HTTP/2 `:authority` pseudo-header), - /// never a raw client-supplied `Host` header, so host-based policy - /// is not bypassable. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host: Option, - - /// HTTP request scheme (`http` / `https`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scheme: Option, -} - -impl HttpExtension { - // -- Request header helpers -- - - /// Set a request header (overwrites if exists). - pub fn set_request_header(&mut self, name: impl Into, value: impl Into) { - self.request_headers.insert(name.into(), value.into()); - } - - /// Get a request header value (case-insensitive lookup). - pub fn get_request_header(&self, name: &str) -> Option<&str> { - get_header_ci(&self.request_headers, name) - } - - /// Check if a request header exists (case-insensitive). - pub fn has_request_header(&self, name: &str) -> bool { - self.get_request_header(name).is_some() - } - - /// Add request header only if it doesn't exist. Returns true if added. - pub fn add_request_header( - &mut self, - name: impl Into, - value: impl Into, - ) -> bool { - let name = name.into(); - if self.has_request_header(&name) { - return false; - } - self.request_headers.insert(name, value.into()); - true - } - - /// Remove a request header by name. Returns the removed value. - pub fn remove_request_header(&mut self, name: &str) -> Option { - remove_header_ci(&mut self.request_headers, name) - } - - // -- Response header helpers -- - - /// Set a response header (overwrites if exists). - pub fn set_response_header(&mut self, name: impl Into, value: impl Into) { - self.response_headers.insert(name.into(), value.into()); - } - - /// Get a response header value (case-insensitive lookup). - pub fn get_response_header(&self, name: &str) -> Option<&str> { - get_header_ci(&self.response_headers, name) - } - - /// Check if a response header exists (case-insensitive). - pub fn has_response_header(&self, name: &str) -> bool { - self.get_response_header(name).is_some() - } - - // -- Convenience aliases (backward-compatible, default to request) -- - - /// Set a header on request headers (convenience alias). - pub fn set_header(&mut self, name: impl Into, value: impl Into) { - self.set_request_header(name, value); - } - - /// Get a header from request headers (convenience alias, case-insensitive). - pub fn get_header(&self, name: &str) -> Option<&str> { - self.get_request_header(name) - } - - /// Check if a request header exists (convenience alias). - pub fn has_header(&self, name: &str) -> bool { - self.has_request_header(name) - } -} - -// -- Internal helpers -- - -fn get_header_ci<'a>(headers: &'a HashMap, name: &str) -> Option<&'a str> { - let lower = name.to_lowercase(); - headers - .iter() - .find(|(k, _)| k.to_lowercase() == lower) - .map(|(_, v)| v.as_str()) -} - -fn remove_header_ci(headers: &mut HashMap, name: &str) -> Option { - let lower = name.to_lowercase(); - let key = headers.keys().find(|k| k.to_lowercase() == lower).cloned(); - key.and_then(|k| headers.remove(&k)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_request_header_set_and_get() { - let mut http = HttpExtension::default(); - http.set_request_header("Content-Type", "application/json"); - assert_eq!( - http.get_request_header("Content-Type"), - Some("application/json") - ); - } - - #[test] - fn test_request_header_case_insensitive() { - let mut http = HttpExtension::default(); - http.set_request_header("Authorization", "Bearer tok"); - assert_eq!(http.get_request_header("authorization"), Some("Bearer tok")); - assert_eq!(http.get_request_header("AUTHORIZATION"), Some("Bearer tok")); - } - - #[test] - fn test_response_header_set_and_get() { - let mut http = HttpExtension::default(); - http.set_response_header("Content-Type", "text/html"); - assert_eq!(http.get_response_header("Content-Type"), Some("text/html")); - assert!(http.has_response_header("content-type")); - } - - #[test] - fn test_request_and_response_independent() { - let mut http = HttpExtension::default(); - http.set_request_header("Authorization", "Bearer req-tok"); - http.set_response_header("X-Response-Time", "42ms"); - - // Request headers don't leak into response - assert!(http.get_response_header("Authorization").is_none()); - // Response headers don't leak into request - assert!(http.get_request_header("X-Response-Time").is_none()); - } - - #[test] - fn test_convenience_aliases_default_to_request() { - let mut http = HttpExtension::default(); - http.set_header("X-Custom", "value"); - assert_eq!(http.get_header("X-Custom"), Some("value")); - assert!(http.has_header("X-Custom")); - // Verify it went to request_headers - assert_eq!(http.get_request_header("X-Custom"), Some("value")); - } - - #[test] - fn test_add_request_header_only_if_absent() { - let mut http = HttpExtension::default(); - assert!(http.add_request_header("X-New", "first")); - assert!(!http.add_request_header("X-New", "second")); - assert_eq!(http.get_request_header("X-New"), Some("first")); - } - - #[test] - fn test_remove_request_header() { - let mut http = HttpExtension::default(); - http.set_request_header("X-Remove", "value"); - let removed = http.remove_request_header("x-remove"); - assert_eq!(removed, Some("value".to_string())); - assert!(!http.has_request_header("X-Remove")); - } - - #[test] - fn test_serde_roundtrip() { - let mut http = HttpExtension::default(); - http.set_request_header("Authorization", "Bearer tok"); - http.set_request_header("X-Request-ID", "req-123"); - http.set_response_header("Content-Type", "application/json"); - http.set_response_header("X-Response-Time", "15ms"); - - let json = serde_json::to_string(&http).unwrap(); - let deserialized: HttpExtension = serde_json::from_str(&json).unwrap(); - - assert_eq!( - deserialized.get_request_header("Authorization"), - Some("Bearer tok") - ); - assert_eq!( - deserialized.get_response_header("Content-Type"), - Some("application/json") - ); - } -} diff --git a/crates/cpex-core/src/extensions/llm.rs b/crates/cpex-core/src/extensions/llm.rs deleted file mode 100644 index adc2225c..00000000 --- a/crates/cpex-core/src/extensions/llm.rs +++ /dev/null @@ -1,27 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/llm.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// LLMExtension — model identity and capabilities. -// Mirrors cpex/framework/extensions/llm.py. - -use serde::{Deserialize, Serialize}; - -/// Model identity and capabilities. -/// -/// Immutable — set by the host. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct LLMExtension { - /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4-20250514"). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_id: Option, - - /// Provider name (e.g., "openai", "anthropic"). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - - /// Model capabilities (e.g., "tool_use", "vision", "streaming"). - #[serde(default)] - pub capabilities: Vec, -} diff --git a/crates/cpex-core/src/extensions/mcp.rs b/crates/cpex-core/src/extensions/mcp.rs deleted file mode 100644 index c5eed384..00000000 --- a/crates/cpex-core/src/extensions/mcp.rs +++ /dev/null @@ -1,115 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/mcp.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// MCPExtension — tool, resource, or prompt metadata. -// Mirrors cpex/framework/extensions/mcp.py. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// MCP tool metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ToolMetadata { - /// Tool name. - pub name: String, - - /// Human-readable title. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - - /// Tool description. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Input JSON schema. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_schema: Option, - - /// Output JSON schema. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_schema: Option, - - /// Source server ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_id: Option, - - /// Tool namespace. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub namespace: Option, - - /// Tool annotations. - #[serde(default)] - pub annotations: HashMap, -} - -/// MCP resource metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ResourceMetadata { - /// Resource URI. - pub uri: String, - - /// Human-readable name. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Resource description. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// MIME type. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - - /// Source server ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_id: Option, - - /// Resource annotations. - #[serde(default)] - pub annotations: HashMap, -} - -/// MCP prompt metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PromptMetadata { - /// Prompt name. - pub name: String, - - /// Prompt description. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Prompt arguments schema. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub arguments: Option>, - - /// Source server ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_id: Option, - - /// Prompt annotations. - #[serde(default)] - pub annotations: HashMap, -} - -/// MCP-specific metadata extension. -/// -/// Carries tool, resource, or prompt metadata for the entity -/// being processed. Immutable — set by the host. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MCPExtension { - /// Tool metadata (if this message involves a tool). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool: Option, - - /// Resource metadata (if this message involves a resource). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resource: Option, - - /// Prompt metadata (if this message involves a prompt). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt: Option, -} diff --git a/crates/cpex-core/src/extensions/meta.rs b/crates/cpex-core/src/extensions/meta.rs deleted file mode 100644 index 4ba55516..00000000 --- a/crates/cpex-core/src/extensions/meta.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/meta.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// MetaExtension — host-provided operational metadata. -// Mirrors cpex/framework/extensions/meta.py. - -use std::collections::{HashMap, HashSet}; - -use serde::{Deserialize, Serialize}; - -/// Host-provided operational metadata. -/// -/// Carries entity identification (type + name) for route resolution, -/// operational tags for policy group inheritance, scope for -/// host-defined grouping, and arbitrary properties. -/// -/// Immutable — set by the host before invoking the hook. Plugins -/// can read but not modify. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MetaExtension { - /// Entity type: "tool", "resource", "prompt", "llm". - /// Used by the manager for route resolution. - #[serde(default)] - pub entity_type: Option, - - /// Entity name: "get_compensation", "hr://employees/*", etc. - /// Used by the manager for route resolution. - #[serde(default)] - pub entity_name: Option, - - /// Operational tags — drive policy group inheritance. - /// Merged with static tags from the matching route's `meta.tags`. - #[serde(default)] - pub tags: HashSet, - - /// Host-defined grouping (virtual server ID, namespace, etc.). - #[serde(default)] - pub scope: Option, - - /// Arbitrary key-value metadata. - #[serde(default)] - pub properties: HashMap, -} diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs deleted file mode 100644 index f1ac0782..00000000 --- a/crates/cpex-core/src/extensions/mod.rs +++ /dev/null @@ -1,63 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Typed extension models for the CPEX framework. -// -// Each extension carries contextual metadata with an explicit -// mutability tier enforced by the processing pipeline. Extensions -// are always passed separately from the payload to handlers. -// -// Mirrors the Python extensions in cpex/framework/extensions/. - -pub mod agent; -pub mod authorization; -pub mod completion; -pub mod container; -pub mod delegation; -pub mod filter; -pub mod framework; -pub mod guarded; -pub mod http; -pub mod llm; -pub mod mcp; -pub mod meta; -pub mod monotonic; -pub mod provenance; -pub mod raw_credentials; -pub mod request; -pub mod routing; -pub mod security; -pub mod tiers; - -// Re-export containers -pub use container::{Extensions, OwnedExtensions}; - -// Re-export all extension types -pub use agent::{AgentExtension, ConversationContext}; -pub use authorization::AuthorizationDetail; -pub use completion::{CompletionExtension, StopReason, TokenUsage}; -pub use delegation::{DelegationExtension, DelegationHop, DelegationStrategy}; -pub use filter::{filter_extensions, SlotName}; -pub use framework::FrameworkExtension; -pub use guarded::{Guarded, WriteToken}; -pub use http::HttpExtension; -pub use llm::LLMExtension; -pub use mcp::{MCPExtension, PromptMetadata, ResourceMetadata, ToolMetadata}; -pub use meta::MetaExtension; -pub use monotonic::{DeclassifierToken, MonotonicSet}; -pub use provenance::ProvenanceExtension; -pub use raw_credentials::{ - DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, RawInboundToken, - TokenKind, TokenRole, -}; -pub use request::RequestExtension; -pub use routing::{ - BackendLabels, CandidateConstraintExtension, OnEmpty, CAP_WRITE_CANDIDATE_CONSTRAINT, -}; -pub use security::{ - ClientExtension, ClientTrustLevel, DataPolicy, ObjectSecurityProfile, RetentionPolicy, - SecurityExtension, SubjectExtension, SubjectType, WorkloadIdentity, -}; -pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/extensions/monotonic.rs b/crates/cpex-core/src/extensions/monotonic.rs deleted file mode 100644 index 371740a5..00000000 --- a/crates/cpex-core/src/extensions/monotonic.rs +++ /dev/null @@ -1,179 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/monotonic.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// MonotonicSet — add-only set enforced at the type level. -// -// Security labels can only grow. The type exposes insert() but not -// remove(). Declassification requires a DeclassifierToken that only -// the security subsystem can construct. -// -// Mirrors the reference implementation spec. - -use std::collections::HashSet; -use std::hash::Hash; - -use serde::{Deserialize, Serialize}; - -/// A set that only allows additions. No remove() in the public API. -/// -/// Plugins can call `insert()` but not `remove()`. Declassification -/// (removal) requires a `DeclassifierToken` that only the security -/// subsystem can construct. -/// -/// This enforces the monotonic tier at compile time — a plugin that -/// tries to call `.remove()` gets a compile error. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(transparent)] -pub struct MonotonicSet { - inner: HashSet, -} - -impl MonotonicSet { - /// Create an empty monotonic set. - pub fn new() -> Self { - Self { - inner: HashSet::new(), - } - } - - /// Create from an existing HashSet. - pub fn from_set(set: HashSet) -> Self { - Self { inner: set } - } - - /// Add a value. Returns true if the value was newly inserted. - pub fn insert(&mut self, value: T) -> bool { - self.inner.insert(value) - } - - /// Check if the set contains a value. - pub fn contains(&self, value: &T) -> bool { - self.inner.contains(value) - } - - /// Iterate over the values. - pub fn iter(&self) -> impl Iterator { - self.inner.iter() - } - - /// Number of elements. - pub fn len(&self) -> usize { - self.inner.len() - } - - /// Whether the set is empty. - pub fn is_empty(&self) -> bool { - self.inner.is_empty() - } - - /// Whether this set is a superset of another. - pub fn is_superset(&self, other: &MonotonicSet) -> bool { - self.inner.is_superset(&other.inner) - } - - /// Get a reference to the inner HashSet (read-only). - pub fn as_set(&self) -> &HashSet { - &self.inner - } - - /// Removal requires a DeclassifierToken — privileged, audited operation. - /// Only the security subsystem can construct the token. - pub fn remove_with_declassifier(&mut self, value: &T, _token: &DeclassifierToken) -> bool { - self.inner.remove(value) - } -} - -impl Default for MonotonicSet { - fn default() -> Self { - Self::new() - } -} - -/// Opaque token for declassification — only the security subsystem -/// can create one. Constructing this token is a privileged operation. -pub struct DeclassifierToken { - _private: (), -} - -impl DeclassifierToken { - /// Only callable by the framework/security subsystem. - #[allow(dead_code)] - pub(crate) fn new() -> Self { - Self { _private: () } - } -} - -/// Case-insensitive label lookup on MonotonicSet. -impl MonotonicSet { - /// Check if a label exists (case-insensitive). - pub fn has_label(&self, label: &str) -> bool { - let lower = label.to_lowercase(); - self.inner.iter().any(|l| l.to_lowercase() == lower) - } - - /// Add a label (case-preserving on insert). - pub fn add_label(&mut self, label: impl Into) { - self.inner.insert(label.into()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_monotonic_insert_only() { - let mut set = MonotonicSet::new(); - set.insert("PII".to_string()); - set.insert("CONFIDENTIAL".to_string()); - assert!(set.contains(&"PII".to_string())); - assert_eq!(set.len(), 2); - // No remove() method available — this is the key guarantee - } - - #[test] - fn test_monotonic_superset() { - let mut before = MonotonicSet::new(); - before.insert("PII".to_string()); - - let mut after = before.clone(); - after.insert("HIPAA".to_string()); - - assert!(after.is_superset(&before)); - assert!(!before.is_superset(&after)); - } - - #[test] - fn test_monotonic_declassifier() { - let mut set = MonotonicSet::new(); - set.insert("PII".to_string()); - - // Only works with the token - let token = DeclassifierToken::new(); - assert!(set.remove_with_declassifier(&"PII".to_string(), &token)); - assert!(!set.contains(&"PII".to_string())); - } - - #[test] - fn test_monotonic_has_label_case_insensitive() { - let mut set = MonotonicSet::new(); - set.add_label("PII"); - assert!(set.has_label("pii")); - assert!(set.has_label("PII")); - assert!(set.has_label("Pii")); - } - - #[test] - fn test_monotonic_serde_roundtrip() { - let mut set = MonotonicSet::new(); - set.insert("PII".to_string()); - set.insert("HIPAA".to_string()); - - let json = serde_json::to_string(&set).unwrap(); - let deserialized: MonotonicSet = serde_json::from_str(&json).unwrap(); - assert!(deserialized.contains(&"PII".to_string())); - assert!(deserialized.contains(&"HIPAA".to_string())); - } -} diff --git a/crates/cpex-core/src/extensions/provenance.rs b/crates/cpex-core/src/extensions/provenance.rs deleted file mode 100644 index 1873f521..00000000 --- a/crates/cpex-core/src/extensions/provenance.rs +++ /dev/null @@ -1,27 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/provenance.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// ProvenanceExtension — origin and message threading. -// Mirrors cpex/framework/extensions/provenance.py. - -use serde::{Deserialize, Serialize}; - -/// Origin and message threading. -/// -/// Immutable — set by the host. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ProvenanceExtension { - /// Source system or service. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, - - /// Unique message identifier. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message_id: Option, - - /// Parent message ID (for threading). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_id: Option, -} diff --git a/crates/cpex-core/src/extensions/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs deleted file mode 100644 index 5fe9ad13..00000000 --- a/crates/cpex-core/src/extensions/raw_credentials.rs +++ /dev/null @@ -1,604 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/raw_credentials.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `RawCredentialsExtension` — Layer 3 of the three-layer credential -// storage model. -// Carries the *raw* token material — bearer JWTs, opaque session -// strings, SPIFFE-JWT-SVIDs, UCAN tokens, transaction tokens — that -// IdentityResolve and TokenDelegate handlers need to do their jobs. -// -// # Why this is its own extension -// -// `SubjectExtension` / `ClientExtension` / `WorkloadIdentity` carry -// *validated* identity — claims already extracted, signature already -// checked, scopes already enumerated. Most plugins want that and -// nothing more. A small set of plugins (identity resolvers, token -// exchangers, forwarding proxies) genuinely need the raw material to -// re-attach it to outbound calls or hand it to an introspection -// endpoint. Separating raw from validated lets us gate the raw layer -// behind narrowly-scoped capabilities (`read_inbound_credentials`, -// `read_delegated_tokens`) so a buggy or malicious plugin without -// those caps can't get at credential strings. -// -// # Serialization safety -// -// `RawInboundToken.token` and `RawDelegatedToken.token` are -// `#[serde(skip)]`. Any normal serialization of an `Extensions` — -// debug dumps, audit logs, trace snapshots, hot-reload bundles — -// produces JSON / YAML where the token field is absent. A deserialize -// then yields a struct with `Zeroizing::new(String::new())` as the -// token, which is explicitly safe (empty bearer authenticates -// nowhere) but a deliberate foot-gun: a plugin that deserializes an -// extension snapshot and expects to find a working token will fail -// loudly, not silently leak credentials by accident. -// -// # Where the process boundary actually is -// -// The `#[serde(skip)]` guard is a guard on *these types*, not a -// guarantee about the host process. It means no generic path that -// serializes an `Extensions` — the `extensions` wire channel to an -// out-of-process host, audit dumps, trace snapshots, hot-reload -// bundles — can carry token bytes. That much is absolute: a plugin -// reading `extensions.raw_credentials` out-of-process sees structure -// and metadata with empty token strings. -// -// It is **not** true that raw material never leaves the host process. -// A host may read the in-memory `Zeroizing` field directly and put -// the plaintext on a purpose-built side channel. `cpex-hosts-python` -// does exactly that: `crates/cpex-hosts-python/src/credentials.rs` -// builds a dedicated `credential` DTO carrying the token as a plain -// string, so an identity resolver or token delegator can run in a -// separate worker process. The reversal is deliberate — without it -// those two handler kinds cannot work out-of-process at all — and it -// is narrow by construction, not by policy: the DTO is a distinct -// type with a hand-written redacting `Debug`, and it is the only -// serialize site anywhere that emits token bytes. -// -// ## What gates the crossing -// -// Raw material may cross a process boundary only when all of these -// hold. They are conditions on the host's dispatch path, not -// properties of this module: -// -// - **The plugin declared the matching capability** — -// `read_inbound_credentials` for inbound tokens, -// `read_delegated_tokens` for delegated ones. The two are -// independently scoped; neither unlocks the other. -// - **The hook is one of the two that model a raw token** — -// `identity_resolve` (`IdentityPayload.raw_token`) and -// `token_delegate` (`DelegationPayload.bearer_token`). Every other -// hook gets nothing, even for a plugin holding both capabilities. -// - **Fail closed on both sides of the gate.** No declared capability -// means no DTO and no token bytes — silently, not as an error. -// A declared capability that cannot be honored (no extension, no -// matching token, an empty or whitespace-only token) is an error -// rather than a no-token dispatch, because a resolver handed an -// empty bearer may read it as "no authentication required". -// -// ## Residual exposure -// -// The gate decides *which plugin* receives a token. It does not -// constrain what happens after: once the plaintext is resident in a -// worker process, every transitively-installed dependency in that -// worker's environment can read it. That is a materially larger and -// less audited trust boundary than this process, and neither the -// capability gate nor the transport closes it — sending raw -// credentials out-of-process means accepting that dependency tree -// into the credential trust boundary. -// -// The mitigations are real but modest, and should not be read as -// closing that gap. The transport is a private pipe inherited only by -// the child (no listening socket, so no other local process can read -// it), the DTO redacts in `Debug`, and the capability gate narrows the -// blast radius to plugins that asked. None of that helps once a -// malicious or compromised dependency shares the worker's address -// space. Keeping the audit story simple is a reason to prefer -// in-process handlers, not an invariant the framework enforces. -// -// # Memory hygiene -// -// `Zeroizing` wipes the underlying bytes when the struct is -// dropped. The protection is real but not absolute — bytes can still -// leak via String::clone, format!, or temporaries created on the way -// to the wrapper. Treat tokens as best-effort cleared, not -// guaranteed. - -use std::collections::HashMap; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; - -/// Which principal a raw inbound token represents. Lookups in -/// `RawCredentialsExtension.inbound_tokens` are by this key. -/// -/// `Custom(String)` is the escape hatch for host-defined roles — -/// HashMap equality is by value, so callers must construct the same -/// `Custom("foo".into())` for both insert and lookup. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TokenRole { - /// The user / subject token (e.g. `id_token`, `X-User-Token`). - User, - /// The OAuth client / gateway-access token (e.g. `Authorization: - /// Bearer ...` from a session JWT). - Client, - /// A JWT-SVID presented by the *calling* workload, when SPIFFE - /// attestation is JWT-based instead of mTLS-based. Maps to - /// `SecurityExtension.caller_workload`. - /// - /// Named for the caller specifically: the gateway's own workload - /// identity (`this_workload`) is a different principal and never - /// appears here, because this map holds *inbound* credentials and - /// the gateway's own credential does not arrive on the wire. - /// - /// `alias = "workload"` keeps configs written before the rename - /// working. - #[serde(rename = "caller_workload", alias = "workload")] - CallerWorkload, - /// Host-defined role. - #[serde(untagged)] - Custom(String), -} - -/// The wire-format family of a raw token. Lets handlers pick the -/// right validation path without parsing the token first. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TokenKind { - /// Standard JWT — three base64url segments joined by dots. - Jwt, - /// Opaque bearer — handler must introspect (RFC 7662) to validate. - Opaque, - /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims. - SpiffeJwt, - /// UCAN capability token. - Ucan, - /// Transaction token — short-lived, single-request scope. - TxnToken, -} - -/// Which principal a delegated outbound token speaks for. Affects -/// scope-narrowing rules, audit-log attribution, and how -/// `DelegationKey` partitions the delegated-token cache. -/// -/// The four variants track the four identity slots on -/// `SecurityExtension` — `subject`, `client`, `caller_workload`, and -/// `this_workload`. Keeping these principals distinct matters: they -/// present different credentials, and a token minted for one must never -/// be handed to another. Only `OnBehalfOfUser` is a genuine -/// *act-on-behalf-of-another* (delegation); `AsClient`, -/// `AsCallerWorkload`, and `AsThisWorkload` are *act-as-self* — the -/// named principal is the one being scoped. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DelegationMode { - /// Outbound token represents the original user (RFC 8693 - /// on-behalf-of / actor-token flows, UCAN delegation). - /// Corresponds to `SecurityExtension.subject`. - OnBehalfOfUser, - - /// Outbound token represents the *calling client / application* - /// acting as itself (`SecurityExtension.client`) — an OAuth client - /// scoping its own credential to a downstream audience, with no user - /// being delegated. The credential exchanged is the client's own - /// token, arriving as `inbound_tokens[TokenRole::Client]`. - /// - /// Like `AsCallerWorkload`, this mode does *not* identify a single - /// principal on its own — many different clients call through one - /// enforcement point, so `DelegationKey.client_id` carries the - /// specific client. Without it, two clients requesting the same - /// audience and scopes would collide on one cache entry. - AsClient, - - /// Outbound token represents the *calling* workload — the attested - /// agent on the inbound network peer - /// (`SecurityExtension.caller_workload`), acting autonomously with - /// no user in the loop. The credential exchanged is the caller's - /// own JWT-SVID, arriving as `inbound_tokens[TokenRole::CallerWorkload]`. - /// - /// Distinct from `AsThisWorkload`: many different agents call through - /// one enforcement point, so this mode does *not* identify a single - /// principal on its own — `DelegationKey.workload_id` carries the - /// specific caller. - AsCallerWorkload, - - /// Outbound token represents *this* CPEX instance's own attested - /// identity (`SecurityExtension.this_workload`) — used when the - /// enforcement point calls infrastructure it owns, or a downstream - /// that trusts only this instance with user context conveyed - /// separately. - /// - /// This instance's SVID is not an inbound credential: it comes from - /// the SPIFFE Workload API, not off the wire. Until a source that - /// populates `this_workload` exists, no handler produces this - /// mode — do not reach for it as a stand-in for - /// `AsCallerWorkload`. - /// - /// `alias = "as_gateway"`: this variant was renamed from `AsGateway`; - /// the alias keeps persisted / serialized `as_gateway` values - /// deserializing. - #[serde(alias = "as_gateway")] - AsThisWorkload, -} - -/// One inbound credential, captured at the wire layer and stashed -/// here by an identity-resolver plugin. Validation happens elsewhere -/// — this struct just carries the bytes and a few hints. -/// -/// The `token` field is `#[serde(skip)]`. Serializing a struct of -/// this type yields `{ "source_header": "...", "kind": "..." }` — -/// the secret material is left out. Deserializing produces a struct -/// whose `token` is `Zeroizing::new(String::new())`. -/// -/// A host that needs the plaintext across a process boundary must -/// read the in-memory field and carry it on a purpose-built channel; -/// a serialize-then-reparse silently yields an empty token. See the -/// module docs for the conditions under which that is permitted. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RawInboundToken { - /// The raw credential bytes. Cleared on drop via `Zeroizing`. - /// **Never serialized** — `#[serde(skip)]` strips this field. - #[serde(skip)] - pub token: Zeroizing, - - /// The HTTP header (or other wire-level slot) the token arrived - /// in — `"Authorization"`, `"X-User-Token"`, etc. Forwarding - /// plugins re-attach under the same name; audit logs cite it. - pub source_header: String, - - /// Wire-format family of the token. Lets handlers route to the - /// right validator without re-parsing the token contents. - pub kind: TokenKind, -} - -impl RawInboundToken { - /// Build a token from raw material + metadata. The most common - /// constructor; identity-resolver plugins call this once per - /// recognized credential. - pub fn new( - token: impl Into, - source_header: impl Into, - kind: TokenKind, - ) -> Self { - Self { - token: Zeroizing::new(token.into()), - source_header: source_header.into(), - kind, - } - } -} - -/// Composite key for cached delegated tokens. Token cache lookups -/// hit on `(subject, workload, audience, scopes, mode)` so different -/// audiences or scope sets for the same subject mint independent -/// tokens. -/// -/// # Every distinguishing principal must appear here -/// -/// A cache key has to carry *everything that makes two token requests -/// different*, or a lookup returns a credential minted for somebody -/// else. `subject_id` alone is not enough: a workload-subject exchange -/// has no user, so `subject_id` is empty for every caller, and two -/// different agents requesting the same audience and scopes would -/// otherwise collide on one key — and be served each other's tokens. -/// `workload_id` is what keeps them apart. -/// -/// `scopes` is a `Vec` (not a `HashSet`) because Cedar / OPA -/// policies frequently care about scope *order* — `["read", "write"]` -/// and `["write", "read"]` may carry different semantics in some IdPs. -/// Callers that want set semantics should sort before constructing. -/// `#[non_exhaustive]`: construct via [`DelegationKey::new`] + the `with_*` -/// setters so adding a future principal slot doesn't break downstream callers. -#[non_exhaustive] -#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)] -pub struct DelegationKey { - /// The user the token speaks for. Empty when no user took part - /// (a workload or client acting as itself). - pub subject_id: String, - - /// SPIFFE-ID of the calling workload, when one participated in - /// the exchange — as the subject (`AsCallerWorkload`) or as the - /// RFC 8693 actor alongside a user. `None` when the exchange - /// involved no workload credential, which keeps ordinary - /// user-only delegations sharing one cache entry instead of - /// being needlessly partitioned per caller. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workload_id: Option, - - /// Client-id of the calling OAuth client, when one participated - /// in the exchange — as the subject (`AsClient`) or as the RFC 8693 - /// actor alongside a user. The exact mirror of `workload_id`: without - /// it, two different clients requesting the same audience and scopes - /// (each with an empty `subject_id`) would collide on one key and be - /// served each other's tokens. `None` when no client credential took - /// part. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - - pub audience: String, - pub scopes: Vec, - pub mode: DelegationMode, -} - -impl DelegationKey { - /// A key for `mode` + `audience` + `scopes`, with no principal ids set. - /// Attach whichever principals participated via the `with_*` setters; - /// this is the construction path `#[non_exhaustive]` requires. - pub fn new(mode: DelegationMode, audience: impl Into, scopes: Vec) -> Self { - Self { - subject_id: String::new(), - workload_id: None, - client_id: None, - audience: audience.into(), - scopes, - mode, - } - } - - /// Set the user principal (`OnBehalfOfUser`). - #[must_use] - pub fn with_subject_id(mut self, subject_id: impl Into) -> Self { - self.subject_id = subject_id.into(); - self - } - - /// Set the calling-workload principal (`AsCallerWorkload`, or a workload actor). - #[must_use] - pub fn with_workload_id(mut self, workload_id: Option) -> Self { - self.workload_id = workload_id; - self - } - - /// Set the calling-client principal (`AsClient`, or a client actor). - #[must_use] - pub fn with_client_id(mut self, client_id: Option) -> Self { - self.client_id = client_id; - self - } -} - -/// One minted outbound credential, produced by a TokenDelegate -/// handler and cached for re-use until expiry. The `token` field is -/// serde-skipped under the same rules as `RawInboundToken.token`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RawDelegatedToken { - /// The minted outbound credential. Cleared on drop. - #[serde(skip)] - pub token: Zeroizing, - - /// Where the consuming plugin should attach the token on the - /// upstream request. Often `"Authorization"`, sometimes - /// audience-specific. - pub outbound_header: String, - - /// The audience the token was minted for. Cache keys include - /// this; the field here is for audit / debugging. - pub audience: String, - - /// Effective scopes on the minted token. May be narrower than - /// the inbound credential's scopes — monotonic narrowing is a - /// framework-level invariant enforced by TokenDelegate. - pub scopes: Vec, - - /// Cache eviction trigger. Handlers re-mint when `now >= - /// expires_at - safety_margin`. - pub expires_at: DateTime, -} - -impl RawDelegatedToken { - pub fn new( - token: impl Into, - outbound_header: impl Into, - audience: impl Into, - scopes: Vec, - expires_at: DateTime, - ) -> Self { - Self { - token: Zeroizing::new(token.into()), - outbound_header: outbound_header.into(), - audience: audience.into(), - scopes, - expires_at, - } - } -} - -/// The Layer-3 raw-credentials extension. -/// -/// Lives on `Extensions.raw_credentials`. Two maps: -/// -/// - `inbound_tokens` — what the wire layer handed us, keyed by -/// `TokenRole`. Populated by identity-resolver plugins. -/// - `delegated_tokens` — what we minted for outbound calls, keyed -/// by `DelegationKey`. Populated by TokenDelegate handlers and -/// read by forwarding / proxy plugins. -/// -/// `plugin_credentials` is intentionally absent until -/// a plugin-credential consumer exists. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RawCredentialsExtension { - /// Raw inbound tokens, captured at request entry by identity - /// resolvers. Read with `read_inbound_credentials`; write with - /// `write_inbound_credentials` (resolvers only). - #[serde(default)] - pub inbound_tokens: HashMap, - - /// Outbound delegated tokens, minted on demand by TokenDelegate - /// handlers and cached for re-use. Read with - /// `read_delegated_tokens`; write with `write_delegated_tokens` - /// (TokenDelegate handlers only). - #[serde(default)] - pub delegated_tokens: HashMap, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn raw_inbound_token_serializes_without_secret() { - let tok = RawInboundToken::new( - "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig", - "Authorization", - TokenKind::Jwt, - ); - let json = serde_json::to_string(&tok).unwrap(); - // The secret string must not appear in the serialized form — - // this is the load-bearing invariant of the whole extension. - assert!( - !json.contains("eyJhbGciOiJSUzI1NiJ9"), - "raw token leaked into serialized form: {}", - json - ); - assert!(json.contains("Authorization")); - assert!(json.contains("jwt")); - } - - #[test] - fn raw_inbound_token_deserializes_with_empty_token() { - let json = r#"{"source_header":"Authorization","kind":"jwt"}"#; - let tok: RawInboundToken = serde_json::from_str(json).unwrap(); - assert_eq!(&*tok.token, ""); - assert_eq!(tok.source_header, "Authorization"); - assert!(matches!(tok.kind, TokenKind::Jwt)); - } - - #[test] - fn raw_delegated_token_serializes_without_secret() { - let tok = RawDelegatedToken::new( - "minted-secret-bytes", - "Authorization", - "https://downstream.example.com", - vec!["read".into()], - Utc::now(), - ); - let json = serde_json::to_string(&tok).unwrap(); - assert!( - !json.contains("minted-secret-bytes"), - "delegated token leaked: {}", - json - ); - assert!(json.contains("downstream.example.com")); - } - - #[test] - fn token_role_custom_is_hashmap_compatible() { - // Documents the lookup pattern — equal Custom values produce - // equal hashes so they collide in a HashMap as expected. - let mut map: HashMap = HashMap::new(); - map.insert(TokenRole::Custom("partner".into()), "p"); - assert_eq!(map.get(&TokenRole::Custom("partner".into())), Some(&"p")); - assert_eq!(map.get(&TokenRole::Custom("other".into())), None); - } - - #[test] - fn delegation_key_hash_eq_consistency() { - let k1 = DelegationKey { - subject_id: "alice".into(), - workload_id: None, - client_id: None, - audience: "https://api.example.com".into(), - scopes: vec!["read".into(), "write".into()], - mode: DelegationMode::OnBehalfOfUser, - }; - let k2 = DelegationKey { - subject_id: "alice".into(), - workload_id: None, - client_id: None, - audience: "https://api.example.com".into(), - scopes: vec!["read".into(), "write".into()], - mode: DelegationMode::OnBehalfOfUser, - }; - assert_eq!(k1, k2); - - // Scope order matters (Vec, not HashSet) — different order is - // intentionally a different key. - let k3 = DelegationKey { - scopes: vec!["write".into(), "read".into()], - ..k1.clone() - }; - assert_ne!(k1, k3); - } - - /// The collision `workload_id` exists to prevent: two different - /// calling agents doing a workload-subject exchange for the same - /// audience and scopes. There is no user, so `subject_id` is empty - /// for both — without the workload in the key they'd collapse to one - /// entry. (Asserts key distinctness only; cross-request serving isn't - /// exercised — nothing reads `delegated_tokens` across requests yet.) - #[test] - fn workload_subject_keys_are_distinct_per_calling_agent() { - let payroll = DelegationKey { - subject_id: String::new(), - workload_id: Some("spiffe://corp/payroll".into()), - client_id: None, - audience: "https://hr.example.com".into(), - scopes: vec!["read".into()], - mode: DelegationMode::AsCallerWorkload, - }; - let recruiting = DelegationKey { - workload_id: Some("spiffe://corp/recruiting".into()), - ..payroll.clone() - }; - assert_ne!( - payroll, recruiting, - "two agents with no user must not share a cache entry", - ); - - // Sanity: everything else being equal, the same agent does - // reuse its own entry — the key isn't accidentally unique. - let payroll_again = payroll.clone(); - assert_eq!(payroll, payroll_again); - - // And a user-only delegation (no workload involved) stays - // distinct from a workload one rather than colliding on the - // empty-subject fallback. - let user_only = DelegationKey { - subject_id: "alice".into(), - workload_id: None, - client_id: None, - audience: "https://hr.example.com".into(), - scopes: vec!["read".into()], - mode: DelegationMode::OnBehalfOfUser, - }; - assert_ne!(payroll, user_only); - } - - #[test] - fn as_gateway_is_a_back_compat_alias_for_this_workload() { - // Persisted `as_gateway` (the pre-rename spelling) must still - // deserialize to AsThisWorkload; the canonical spelling also works. - assert_eq!( - serde_json::from_str::("\"as_gateway\"").unwrap(), - DelegationMode::AsThisWorkload, - ); - assert_eq!( - serde_json::from_str::("\"as_this_workload\"").unwrap(), - DelegationMode::AsThisWorkload, - ); - } - - #[test] - fn extension_round_trip_drops_tokens() { - let mut ext = RawCredentialsExtension::default(); - ext.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new("user-jwt", "X-User-Token", TokenKind::Jwt), - ); - - let json = serde_json::to_string(&ext).unwrap(); - assert!(!json.contains("user-jwt")); - - let restored: RawCredentialsExtension = serde_json::from_str(&json).unwrap(); - // Round-trip preserves the structure but strips secret material. - let restored_tok = restored.inbound_tokens.get(&TokenRole::User).unwrap(); - assert_eq!(&*restored_tok.token, ""); - assert_eq!(restored_tok.source_header, "X-User-Token"); - } -} diff --git a/crates/cpex-core/src/extensions/request.rs b/crates/cpex-core/src/extensions/request.rs deleted file mode 100644 index 435ab1fa..00000000 --- a/crates/cpex-core/src/extensions/request.rs +++ /dev/null @@ -1,35 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/request.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// RequestExtension — execution environment and tracing. -// Mirrors cpex/framework/extensions/request.py. - -use serde::{Deserialize, Serialize}; - -/// Execution environment and request tracing. -/// -/// Immutable — set by the host before invoking the hook. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RequestExtension { - /// Deployment environment (e.g., "production", "staging"). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment: Option, - - /// Unique request identifier. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_id: Option, - - /// Request timestamp (ISO 8601). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option, - - /// Distributed trace ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace_id: Option, - - /// Span ID within the trace. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub span_id: Option, -} diff --git a/crates/cpex-core/src/extensions/routing.rs b/crates/cpex-core/src/extensions/routing.rs deleted file mode 100644 index 8d197933..00000000 --- a/crates/cpex-core/src/extensions/routing.rs +++ /dev/null @@ -1,516 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/routing.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CandidateConstraintExtension — the backend candidate constraint the -// APL `restrict` effect produces, carried as a typed extension slot. -// -// The policy engine (apl-cpex) folds every `restrict` a request emitted -// into one of these and writes it into `Extensions.candidate_constraint`. -// The host router/load-balancer (Praxis's policy filter) reads it TYPED -// off `PipelineResult.modified_extensions` — the same in-process, -// type-shared channel `raw_credentials.delegated_tokens` rides — and -// narrows its candidate set accordingly. It is a routing directive, not -// an access decision: it never picks a backend and never allows/denies. - -use std::collections::{BTreeMap, HashMap}; - -use serde::{Deserialize, Serialize}; - -/// What the host does when the constraint prunes every candidate. -/// -/// The host — not CPEX — makes the empty decision, since only it knows -/// which backends are actually reachable/healthy at selection time. The -/// choice rides out with the constraint. Fail-closed by default. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OnEmpty { - /// Reject the request (fail-closed). Correct for hard constraints - /// like data sovereignty — never silently escape the region. - #[default] - Deny, - /// Fall back to the unconstrained candidate set. Explicit opt-in for - /// "prefer, but don't fail" cases. - Fallback, -} - -/// The folded, request-level backend constraint emitted by APL `restrict` -/// effects. One per request; the policy engine intersects every restrict -/// that fired into this single value (allow-sets narrow, deny/custom -/// grow, tier ceilings collect, `on_empty` takes the strictest). All -/// monotone — it can only shrink the eligible set. -/// -/// Field shape mirrors the authoring form (`apl_core::CandidateConstraint`) -/// with one divergence: `max_cost_tier` (one ceiling per restrict) folds -/// to `max_cost_tiers` (the *set* of ceilings). CPEX cannot order tier -/// names — that ordering is host-owned — so it emits every ceiling and -/// the host requires `cost_tier ≤ all of them`, which is `≤ min` once the -/// host applies its own tier order. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct CandidateConstraintExtension { - /// Candidate `model` must be in this set (glob-matched). `None` = no - /// model allow-list. `Some(empty)` means nothing qualifies → the - /// host's `on_empty` fires. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_models: Option>, - - /// Candidate `model` must NOT match any of these (glob-matched). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub deny_models: Vec, - - /// Candidate `region` must be in this set (equality). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_regions: Option>, - - /// Candidate `site` must be in this set (equality). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allow_sites: Option>, - - /// Every `cost_tier` ceiling emitted, de-duplicated. The host - /// requires `cost_tier ≤` **all** of these (== `≤ min` under the - /// host's tier order). CPEX never orders them itself. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub max_cost_tiers: Vec, - - /// Arbitrary backend labels the candidate must carry, matched by - /// plain equality (k8s `nodeSelector` semantics). - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub custom: BTreeMap, - - /// What the host does when the constraint leaves no eligible backend. - #[serde(default)] - pub on_empty: OnEmpty, -} - -impl CandidateConstraintExtension { - /// True when nothing is constrained (every field unset). `on_empty` - /// is ignored — on its own it restricts nothing. The policy engine - /// skips writing an empty constraint into the extension slot. - pub fn is_empty(&self) -> bool { - self.allow_models.is_none() - && self.deny_models.is_empty() - && self.allow_regions.is_none() - && self.allow_sites.is_none() - && self.max_cost_tiers.is_empty() - && self.custom.is_empty() - } - - /// Does `backend` satisfy this constraint? This is the executable half - /// of the seam contract — the host router (Praxis) calls - /// it per candidate to prune its eligible set, instead of - /// reimplementing the matcher. All field semantics live here, once: - /// - /// - `allow_models` / `deny_models` — **glob** against the `model` - /// label (`anthropic/*`), not equality. - /// - `allow_regions` / `allow_sites` — set membership (equality) on - /// the `region` / `site` labels. - /// - `max_cost_tiers` — the `cost_tier` label must be `≤` **every** - /// ceiling. `tier_rank` maps a tier name to its order (lower = - /// cheaper); **the host owns that ordering** — the algorithm is - /// ours, the vocabulary is theirs. `≤ every ceiling` is `≤ min`. - /// - `custom` — the backend must carry every label with an equal value. - /// - /// **Fail-closed** throughout: a constrained attribute the backend - /// lacks (no `model` label under an `allow_models`, no `cost_tier` - /// under a ceiling, an unrankable tier) excludes the backend rather - /// than matching it. An empty constraint accepts everything. - /// - /// This is per-backend and does **not** apply `on_empty` — that is the - /// caller's decision once it knows the surviving set is empty (only - /// the router knows reachability). See [`Self::on_empty`]. - /// - /// ``` - /// use std::collections::BTreeMap; - /// use cpex_core::extensions::routing::CandidateConstraintExtension; - /// - /// let c = CandidateConstraintExtension { - /// allow_regions: Some(vec!["eu".into()]), - /// ..Default::default() - /// }; - /// let eu: BTreeMap = [("region".into(), "eu".into())].into(); - /// let us: BTreeMap = [("region".into(), "us".into())].into(); - /// assert!(c.accepts(&eu, |_| None)); - /// assert!(!c.accepts(&us, |_| None)); - /// ``` - pub fn accepts( - &self, - backend: &impl BackendLabels, - tier_rank: impl Fn(&str) -> Option, - ) -> bool { - // allow_models: the model must glob-match at least one pattern. - if let Some(allow) = &self.allow_models { - match backend.label(LABEL_MODEL) { - Some(m) if allow.iter().any(|pat| glob_match(pat, m)) => {}, - _ => return false, - } - } - // deny_models: the model must not glob-match any pattern. - if let Some(m) = backend.label(LABEL_MODEL) { - if self.deny_models.iter().any(|pat| glob_match(pat, m)) { - return false; - } - } - // allow_regions / allow_sites: equality set membership. - if let Some(allow) = &self.allow_regions { - match backend.label(LABEL_REGION) { - Some(r) if allow.iter().any(|x| x == r) => {}, - _ => return false, - } - } - if let Some(allow) = &self.allow_sites { - match backend.label(LABEL_SITE) { - Some(s) if allow.iter().any(|x| x == s) => {}, - _ => return false, - } - } - // max_cost_tiers: cost_tier must rank ≤ every ceiling. - if !self.max_cost_tiers.is_empty() { - let Some(bt) = backend.label(LABEL_COST_TIER) else { - return false; - }; - let Some(backend_rank) = tier_rank(bt) else { - return false; // unrankable backend tier — fail closed - }; - for ceiling in &self.max_cost_tiers { - match tier_rank(ceiling) { - Some(ceiling_rank) if backend_rank <= ceiling_rank => {}, - _ => return false, // exceeds ceiling, or ceiling unrankable - } - } - } - // custom: every required label present with an equal value. - for (k, v) in &self.custom { - match backend.label(k) { - Some(bv) if bv == v => {}, - _ => return false, - } - } - true - } -} - -/// Capability a plugin must hold to create, change, or remove the -/// [`CandidateConstraintExtension`] slot (`Extensions.candidate_constraint`). -/// -/// The slot is the policy engine's routing *output*: the APL engine holds -/// this capability intrinsically (emitting the constraint is its own job, -/// like taints), and any other plugin that alters the slot by value without -/// holding it has its extension modifications rejected by the executor — -/// so a downstream plugin cannot overwrite, remove, or forge the policy -/// engine's constraint. A holder that leaves the slot untouched always -/// passes. Read access is ungated in v1 (the host consumes the constraint -/// after the pipeline, not through a filtered plugin view). -/// -/// If a future writer other than the APL engine is introduced, grant it this -/// capability; multi-writer composition should be monotonic (allow-sets -/// intersect, deny-sets union) so no writer can weaken another's constraint. -pub const CAP_WRITE_CANDIDATE_CONSTRAINT: &str = "write_candidate_constraint"; - -/// Well-known backend label keys the typed constraint fields match -/// against. Everything else in a backend's label set is `custom`. -pub const LABEL_MODEL: &str = "model"; -pub const LABEL_REGION: &str = "region"; -pub const LABEL_SITE: &str = "site"; -pub const LABEL_COST_TIER: &str = "cost_tier"; - -/// A backend's labels, looked up by key — the input to -/// [`CandidateConstraintExtension::accepts`]. Implemented for the standard -/// string maps; a host whose backend labels live elsewhere implements it -/// directly (one method, no allocation). -pub trait BackendLabels { - /// The value of label `key`, or `None` if the backend has no such label. - fn label(&self, key: &str) -> Option<&str>; -} - -impl BackendLabels for BTreeMap { - fn label(&self, key: &str) -> Option<&str> { - self.get(key).map(String::as_str) - } -} - -impl BackendLabels for HashMap { - fn label(&self, key: &str) -> Option<&str> { - self.get(key).map(String::as_str) - } -} - -/// Match `text` against a glob `pattern` where `*` matches any run of -/// characters (including empty). No other metacharacters — model ids are -/// the use case (`anthropic/*`, `*/claude-sonnet-4`, `vllm/*`). Operates -/// on bytes (model ids are ASCII); linear time with single-star backtrack. -fn glob_match(pattern: &str, text: &str) -> bool { - let p = pattern.as_bytes(); - let t = text.as_bytes(); - let (mut pi, mut ti) = (0usize, 0usize); - // Backtrack point: the last `*` seen and the text index to resume from. - let mut star: Option = None; - let mut resume = 0usize; - while ti < t.len() { - if pi < p.len() && p[pi] == t[ti] { - pi += 1; - ti += 1; - } else if pi < p.len() && p[pi] == b'*' { - star = Some(pi); - resume = ti; - pi += 1; // try matching `*` against zero chars first - } else if let Some(s) = star { - // Mismatch after a `*` — let the `*` swallow one more char. - pi = s + 1; - resume += 1; - ti = resume; - } else { - return false; - } - } - // Trailing `*`s match the empty remainder. - while pi < p.len() && p[pi] == b'*' { - pi += 1; - } - pi == p.len() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_empty_true_for_default() { - assert!(CandidateConstraintExtension::default().is_empty()); - } - - #[test] - fn is_empty_ignores_on_empty() { - let c = CandidateConstraintExtension { - on_empty: OnEmpty::Fallback, - ..Default::default() - }; - assert!(c.is_empty()); - } - - #[test] - fn empty_intersection_is_not_empty() { - // Some([]) is a real (impossible) constraint, not "unset". - let c = CandidateConstraintExtension { - allow_regions: Some(vec![]), - ..Default::default() - }; - assert!(!c.is_empty()); - } - - #[test] - fn json_omits_empty_fields() { - let c = CandidateConstraintExtension { - allow_regions: Some(vec!["eu".into()]), - ..Default::default() - }; - let json = serde_json::to_value(&c).unwrap(); - assert_eq!( - json, - serde_json::json!({ "allow_regions": ["eu"], "on_empty": "deny" }) - ); - } - - #[test] - fn json_roundtrips() { - let c = CandidateConstraintExtension { - allow_models: Some(vec!["vllm/*".into()]), - deny_models: vec!["openai/*".into()], - max_cost_tiers: vec!["cheap".into(), "standard".into()], - custom: [("gpu".to_string(), "h100".to_string())].into(), - on_empty: OnEmpty::Fallback, - ..Default::default() - }; - let back: CandidateConstraintExtension = - serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap(); - assert_eq!(c, back); - } - - // ----- glob_match ----- - - #[test] - fn glob_exact_and_star() { - assert!(glob_match("anthropic/claude-4", "anthropic/claude-4")); - assert!(!glob_match("anthropic/claude-4", "anthropic/claude-5")); - assert!(glob_match("*", "anything/at-all")); - assert!(glob_match("*", "")); - } - - #[test] - fn glob_prefix_suffix_middle() { - assert!(glob_match("anthropic/*", "anthropic/claude-sonnet-4")); - assert!(!glob_match("anthropic/*", "openai/gpt-4")); - assert!(glob_match("*/claude-sonnet-4", "anthropic/claude-sonnet-4")); - assert!(glob_match("anthropic/*-4", "anthropic/claude-sonnet-4")); - assert!(!glob_match("anthropic/*-4", "anthropic/claude-sonnet-5")); - } - - #[test] - fn glob_star_matches_empty_run() { - assert!(glob_match("vllm/*", "vllm/")); - assert!(glob_match("a*b*c", "abc")); - assert!(glob_match("a*b*c", "axxbxxc")); - assert!(!glob_match("a*b*c", "axxb")); // missing trailing c - } - - // ----- accepts ----- - - fn backend(pairs: &[(&str, &str)]) -> std::collections::BTreeMap { - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - /// cheap < standard < premium. - fn rank(t: &str) -> Option { - match t { - "cheap" => Some(0), - "standard" => Some(1), - "premium" => Some(2), - _ => None, - } - } - - #[test] - fn empty_constraint_accepts_everything() { - let c = CandidateConstraintExtension::default(); - assert!(c.accepts(&backend(&[("region", "eu")]), rank)); - assert!(c.accepts(&backend(&[]), rank)); - } - - #[test] - fn allow_models_globs() { - let c = CandidateConstraintExtension { - allow_models: Some(vec!["anthropic/*".into(), "vllm/*".into()]), - ..Default::default() - }; - assert!(c.accepts(&backend(&[("model", "anthropic/claude-4")]), rank)); - assert!(c.accepts(&backend(&[("model", "vllm/llama")]), rank)); - assert!(!c.accepts(&backend(&[("model", "openai/gpt-4")]), rank)); - // No model label under an allow_models → excluded (fail-closed). - assert!(!c.accepts(&backend(&[("region", "eu")]), rank)); - } - - #[test] - fn deny_models_globs() { - let c = CandidateConstraintExtension { - deny_models: vec!["openai/*".into()], - ..Default::default() - }; - assert!(!c.accepts(&backend(&[("model", "openai/gpt-4")]), rank)); - assert!(c.accepts(&backend(&[("model", "anthropic/claude-4")]), rank)); - // No model label → not denied → passes. - assert!(c.accepts(&backend(&[("region", "eu")]), rank)); - } - - #[test] - fn allow_regions_equality() { - let c = CandidateConstraintExtension { - allow_regions: Some(vec!["eu".into()]), - ..Default::default() - }; - assert!(c.accepts(&backend(&[("region", "eu")]), rank)); - assert!(!c.accepts(&backend(&[("region", "us")]), rank)); - assert!(!c.accepts(&backend(&[]), rank)); // no region → excluded - } - - #[test] - fn max_cost_tiers_ceiling() { - let c = CandidateConstraintExtension { - max_cost_tiers: vec!["standard".into()], - ..Default::default() - }; - assert!(c.accepts(&backend(&[("cost_tier", "cheap")]), rank)); // 0 <= 1 - assert!(c.accepts(&backend(&[("cost_tier", "standard")]), rank)); // 1 <= 1 - assert!(!c.accepts(&backend(&[("cost_tier", "premium")]), rank)); // 2 > 1 - assert!(!c.accepts(&backend(&[]), rank)); // no cost_tier → excluded - } - - #[test] - fn max_cost_tiers_multiple_is_the_min() { - // Backend must be ≤ every ceiling → ≤ min(cheap, standard) = cheap. - let c = CandidateConstraintExtension { - max_cost_tiers: vec!["cheap".into(), "standard".into()], - ..Default::default() - }; - assert!(c.accepts(&backend(&[("cost_tier", "cheap")]), rank)); - assert!(!c.accepts(&backend(&[("cost_tier", "standard")]), rank)); // > cheap - } - - #[test] - fn unrankable_tier_fails_closed() { - let c = CandidateConstraintExtension { - max_cost_tiers: vec!["standard".into()], - ..Default::default() - }; - // Backend tier the host can't rank → excluded, not matched. - assert!(!c.accepts(&backend(&[("cost_tier", "mystery")]), rank)); - } - - #[test] - fn unrankable_ceiling_fails_closed() { - // The *ceiling* itself (the policy value, e.g. a typo'd or - // host-unknown `max_cost_tier`) is unrankable. `accepts` must reject - // rather than silently drop the ceiling and admit a rankable backend. - let c = CandidateConstraintExtension { - max_cost_tiers: vec!["mystery".into()], - ..Default::default() - }; - // Backend tier is perfectly rankable; only the ceiling is not. - assert!(!c.accepts(&backend(&[("cost_tier", "cheap")]), rank)); - } - - #[test] - fn custom_labels_equality() { - let c = CandidateConstraintExtension { - custom: [("gpu".to_string(), "h100".to_string())].into(), - ..Default::default() - }; - assert!(c.accepts(&backend(&[("gpu", "h100")]), rank)); - assert!(!c.accepts(&backend(&[("gpu", "a100")]), rank)); // wrong value - assert!(!c.accepts(&backend(&[("region", "eu")]), rank)); // label absent - } - - #[test] - fn all_fields_must_pass_together() { - let c = CandidateConstraintExtension { - allow_regions: Some(vec!["eu".into()]), - deny_models: vec!["openai/*".into()], - max_cost_tiers: vec!["standard".into()], - custom: [("gpu".to_string(), "h100".to_string())].into(), - ..Default::default() - }; - // Satisfies everything. - assert!(c.accepts( - &backend(&[ - ("region", "eu"), - ("model", "anthropic/claude-4"), - ("cost_tier", "cheap"), - ("gpu", "h100"), - ]), - rank - )); - // One field off (region) → rejected. - assert!(!c.accepts( - &backend(&[ - ("region", "us"), - ("model", "anthropic/claude-4"), - ("cost_tier", "cheap"), - ("gpu", "h100"), - ]), - rank - )); - } - - #[test] - fn accepts_works_with_hashmap_labels() { - let c = CandidateConstraintExtension { - allow_regions: Some(vec!["eu".into()]), - ..Default::default() - }; - let labels: std::collections::HashMap = - [("region".to_string(), "eu".to_string())].into(); - assert!(c.accepts(&labels, rank)); - } -} diff --git a/crates/cpex-core/src/extensions/security.rs b/crates/cpex-core/src/extensions/security.rs deleted file mode 100644 index 6c9896ef..00000000 --- a/crates/cpex-core/src/extensions/security.rs +++ /dev/null @@ -1,518 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/security.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// SecurityExtension — labels, classification, identity, data policy. -// Mirrors cpex/framework/extensions/security.py. - -use std::collections::{HashMap, HashSet}; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::monotonic::MonotonicSet; - -/// Subject type for identity classification. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SubjectType { - User, - Agent, - Service, - System, -} - -/// Authenticated subject identity. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SubjectExtension { - /// Subject identifier (e.g., JWT sub). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Subject type. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_type: Option, - - /// Assigned roles. - #[serde(default)] - pub roles: HashSet, - - /// Granted permissions. - #[serde(default)] - pub permissions: HashSet, - - /// Team memberships. - #[serde(default)] - pub teams: HashSet, - - /// Raw claims (e.g., JWT claims). - #[serde(default)] - pub claims: HashMap, -} - -/// Security profile for a managed object. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ObjectSecurityProfile { - /// Who manages this object. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub managed_by: Option, - - /// Required permissions. - #[serde(default)] - pub permissions: Vec, - - /// Trust domain. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trust_domain: Option, - - /// Data scope. - #[serde(default)] - pub data_scope: Vec, -} - -/// Retention policy for data. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RetentionPolicy { - /// Maximum age in seconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_age_seconds: Option, - - /// Policy name. - #[serde(default)] - pub policy: String, - - /// Deletion timestamp. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delete_after: Option, -} - -/// Data policy for a named data element. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DataPolicy { - /// Labels to apply. - #[serde(default)] - pub apply_labels: Vec, - - /// Allowed actions (None = all allowed). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allowed_actions: Option>, - - /// Denied actions. - #[serde(default)] - pub denied_actions: Vec, - - /// Retention policy. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retention: Option, -} - -/// Trust classification for the OAuth client / gateway that brokered -/// the request. Distinct from the *user's* subject identity — the same -/// human can connect through a first-party browser flow or a -/// third-party agent, and policies often want to distinguish them. -/// -/// `Custom(String)` lets operators carry a finer-grained vocabulary -/// (e.g. `"partner-tier-A"`) without forking the type. The enum is -/// `#[non_exhaustive]` so new well-known variants can be added later -/// without breaking external matches. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ClientTrustLevel { - /// First-party clients operated by the same org as this gateway. - FirstParty, - /// External third-party clients, integrated but not operated by us. - ThirdParty, - /// Internal infrastructure clients (control plane, ops tooling). - Internal, - /// Operator-defined trust level — string carried verbatim into - /// policy. Lookups by value (Hash + Eq) work as long as both - /// sides construct identical strings. - #[serde(untagged)] - Custom(String), -} - -impl Default for ClientTrustLevel { - /// Default to the most restrictive well-known level so a - /// missing-or-misconfigured client doesn't silently inherit - /// first-party privileges. - fn default() -> Self { - ClientTrustLevel::ThirdParty - } -} - -/// The OAuth client / gateway-access principal — *what application* -/// is brokering the request, as opposed to *which user* is using it -/// (`SubjectExtension`) and *which attested workload* is the network -/// peer (`WorkloadIdentity`). Populated from a client-credentials or -/// session JWT by an identity-resolver plugin (or supplied directly -/// by a trusted upstream gateway). -/// -/// The shape is deliberately symmetric with `SubjectExtension` — -/// roles / permissions / teams / claims appear on both. That lets APL -/// policies write `client.roles.contains("partner")` and -/// `subject.roles.contains("admin")` with the same idiom; some IdPs -/// (Keycloak service accounts, Auth0 M2M apps, AWS IAM role grants) -/// attach RBAC grants to clients directly. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ClientExtension { - /// OAuth `client_id` — required. Anchor identifier for the client. - pub client_id: String, - - /// Human-readable client name from the IdP. Useful for audit logs. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_name: Option, - - /// Trust classification — see [`ClientTrustLevel`]. - #[serde(default)] - pub trust_level: ClientTrustLevel, - - /// OAuth scopes the IdP authorized for this client (across all - /// audiences). Policy authors use this to gate on what the IdP - /// believes the client is allowed to ask for, before checking - /// whether the specific request stays within those scopes. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub authorized_scopes: Vec, - - /// OAuth audiences the IdP authorized this client to address. - /// Different IdPs encode this differently; the resolver - /// normalizes them into this list. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub authorized_audiences: Vec, - - /// Platform-native RBAC roles attached to the client (Keycloak - /// service-account-roles, Auth0 M2M permissions, IAM role grants). - /// Distinct from `authorized_scopes` — scopes are OAuth-issued, - /// roles are platform-issued. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub roles: Vec, - - /// Platform-native permissions attached to the client. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub permissions: Vec, - - /// Team / tenant / account memberships, for multi-tenant - /// platforms that scope clients to organizational units. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub teams: Vec, - - /// Raw remaining JWT claims (or equivalent), keyed by claim name. - /// `Value` (not `String`) because claim values can be booleans, - /// numbers, nested objects, arrays — policy authors who reach - /// here generally know the claim's expected shape. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub claims: HashMap, -} - -/// SPIFFE-style workload identity, used for both inbound callers -/// (`SecurityExtension.caller_workload` — added in a subsequent slice) -/// and our own outbound identity (`SecurityExtension.this_workload`). -/// -/// Distinct from `SubjectExtension` (the human/agent caller) and -/// `ClientExtension` (the OAuth client, added in a subsequent slice). -/// Where `Subject` is "who", `Client` is "what app", `Workload` is -/// "which attested process" — typically established at the network -/// edge via mTLS or a SPIFFE attestation API and never present on -/// the same request as an unauthenticated principal. -/// -/// Populated by the framework / identity-resolver plugin from -/// attestation evidence. Plugins read it via the `read_workload` -/// capability. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct WorkloadIdentity { - /// SPIFFE-SVID identifier — `spiffe:///`. - /// Set when the workload presented a SPIFFE-SVID (X.509 or JWT) - /// or otherwise carries a SPIFFE-shaped identity. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spiffe_id: Option, - - /// Trust domain extracted from the SPIFFE-SVID (or supplied by - /// the attestation source for non-SPIFFE attestors). Lets policy - /// authors gate on the trust boundary without parsing the URI. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trust_domain: Option, - - /// When the attestation was performed. Useful for stale-evidence - /// rejection in policy. Populated by the attestor; the framework - /// doesn't refresh it on its own. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attested_at: Option>, - - /// Name of the attestor that vouched for the workload — `mtls`, - /// `spire-agent`, `aws-iid`, `gke-workload-identity`, etc. The - /// vocabulary is open; operators document the values they use. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attestor: Option, - - /// SPIFFE workload selectors — `k8s:ns:foo`, `unix:uid:1000`, … - /// Empty when no selectors were attached (the SPIFFE-ID alone is - /// the workload's identity). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub selectors: Vec, - - /// OAuth client_id, when the workload also carries one. Kept - /// alongside SPIFFE so call sites with both shapes (a SPIFFE - /// workload that's *also* registered as an OAuth client to a - /// dynamic-client-registration IdP) don't have to populate two - /// extensions. The OAuth client's authorization data - /// (scopes / audiences / claims) lives on the separate - /// `ClientExtension` slot, not here. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, -} - -/// Security-related extensions. -/// -/// Carries security labels (monotonic add-only), classification, -/// up to four distinct identity principals, and data-policy metadata. -/// The four principal slots map to the identity sources: -/// -/// - `subject` — the *user* (or service-as-user) initiating the request -/// - `client` — the *OAuth client / application* brokering the request -/// - `caller_workload` — the *attested workload* on the inbound network -/// peer (SPIFFE-SVID, mTLS cert chain) -/// - `this_workload` — *our own* gateway's attested identity, used for -/// outbound calls -/// -/// A request can populate any subset; identity-resolver plugins are -/// expected to fill the slots they're configured for. Policy authors -/// reason about all four uniformly through the `subject.*` / -/// `client.*` / `caller_workload.*` / `this_workload.*` bag namespaces. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SecurityExtension { - /// Security labels (monotonic — add-only via MonotonicSet). - /// No remove() method — enforced at compile time. - #[serde(default)] - pub labels: MonotonicSet, - - /// Data classification level. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub classification: Option, - - /// Authenticated *user* identity (who is calling). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, - - /// Authenticated *OAuth client / application* brokering the - /// request. Distinct from `subject` — the same user can connect - /// through different clients (first-party web, third-party - /// integration), and policies sometimes want to gate on which. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client: Option, - - /// The inbound caller's attested workload identity — the network - /// peer's SPIFFE-SVID or mTLS-attested identity. Distinct from - /// `client` (the OAuth-layer identity of the application) and - /// `subject` (the user). All three can be present on the same - /// request when an agent acts on behalf of a user through our - /// gateway, peered via mTLS. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub caller_workload: Option, - - /// This agent / gateway's own workload identity — the SPIFFE-SVID - /// or attested identity *we* present when making outbound calls. - /// Populated by the host at startup, not per request. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub this_workload: Option, - - /// Authentication method used (e.g., "jwt", "mtls", "spiffe", "api_key"). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_method: Option, - - /// Object security profiles keyed by object name. - #[serde(default)] - pub objects: HashMap, - - /// Data policies keyed by data element name. - #[serde(default)] - pub data: HashMap, -} - -impl SecurityExtension { - /// Add a security label (monotonic — cannot remove). - pub fn add_label(&mut self, label: impl Into) { - self.labels.add_label(label); - } - - /// Check if a label exists (case-insensitive). - pub fn has_label(&self, label: &str) -> bool { - self.labels.has_label(label) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_security_labels_monotonic() { - let mut sec = SecurityExtension::default(); - sec.add_label("PII"); - sec.add_label("HIPAA"); - assert!(sec.has_label("PII")); - assert!(sec.has_label("pii")); // case-insensitive - assert!(sec.has_label("HIPAA")); - assert!(!sec.has_label("SOX")); - } - - #[test] - fn test_security_classification() { - let sec = SecurityExtension { - classification: Some("confidential".into()), - ..Default::default() - }; - assert_eq!(sec.classification.as_deref(), Some("confidential")); - } - - #[test] - fn test_subject_extension() { - let subject = SubjectExtension { - id: Some("alice".into()), - subject_type: Some(SubjectType::User), - roles: ["admin".to_string(), "hr".to_string()].into(), - permissions: ["read_all".to_string()].into(), - teams: ["engineering".to_string()].into(), - claims: [("iss".to_string(), "auth.example.com".to_string())].into(), - }; - assert_eq!(subject.id.as_deref(), Some("alice")); - assert_eq!(subject.subject_type, Some(SubjectType::User)); - assert!(subject.roles.contains("admin")); - assert!(subject.permissions.contains("read_all")); - assert!(subject.teams.contains("engineering")); - assert_eq!(subject.claims.get("iss").unwrap(), "auth.example.com"); - } - - #[test] - fn test_workload_identity() { - let w = WorkloadIdentity { - spiffe_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), - trust_domain: Some("example.com".into()), - attestor: Some("spire-agent".into()), - selectors: vec!["k8s:ns:team1".into(), "k8s:sa:weather-tool".into()], - client_id: Some("weather-agent".into()), - ..Default::default() - }; - assert_eq!( - w.spiffe_id.as_deref(), - Some("spiffe://example.com/ns/team1/sa/weather-tool") - ); - assert_eq!(w.trust_domain.as_deref(), Some("example.com")); - assert_eq!(w.attestor.as_deref(), Some("spire-agent")); - assert_eq!(w.selectors.len(), 2); - assert_eq!(w.client_id.as_deref(), Some("weather-agent")); - } - - #[test] - fn test_workload_identity_default() { - let w = WorkloadIdentity::default(); - assert!(w.spiffe_id.is_none()); - assert!(w.trust_domain.is_none()); - assert!(w.attested_at.is_none()); - assert!(w.attestor.is_none()); - assert!(w.selectors.is_empty()); - assert!(w.client_id.is_none()); - } - - #[test] - fn test_security_with_this_workload_and_subject() { - let sec = SecurityExtension { - labels: { - let mut l = super::super::MonotonicSet::new(); - l.add_label("PII"); - l - }, - classification: Some("confidential".into()), - subject: Some(SubjectExtension { - id: Some("alice".into()), - subject_type: Some(SubjectType::User), - ..Default::default() - }), - this_workload: Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/hr-agent".into()), - trust_domain: Some("corp.com".into()), - client_id: Some("hr-agent".into()), - ..Default::default() - }), - auth_method: Some("jwt".into()), - ..Default::default() - }; - - // Caller identity - assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); - // Our own workload identity (distinct from caller) - assert_eq!( - sec.this_workload.as_ref().unwrap().client_id.as_deref(), - Some("hr-agent") - ); - assert_eq!( - sec.this_workload.as_ref().unwrap().trust_domain.as_deref(), - Some("corp.com") - ); - // Auth method - assert_eq!(sec.auth_method.as_deref(), Some("jwt")); - // Labels - assert!(sec.has_label("PII")); - } - - #[test] - fn test_security_serde_roundtrip() { - let mut sec = SecurityExtension::default(); - sec.add_label("PII"); - sec.classification = Some("internal".into()); - sec.this_workload = Some(WorkloadIdentity { - client_id: Some("my-agent".into()), - ..Default::default() - }); - sec.auth_method = Some("mtls".into()); - - let json = serde_json::to_string(&sec).unwrap(); - let deserialized: SecurityExtension = serde_json::from_str(&json).unwrap(); - - assert!(deserialized.has_label("PII")); - assert_eq!(deserialized.classification.as_deref(), Some("internal")); - assert_eq!( - deserialized - .this_workload - .as_ref() - .unwrap() - .client_id - .as_deref(), - Some("my-agent") - ); - assert_eq!(deserialized.auth_method.as_deref(), Some("mtls")); - } - - #[test] - fn test_object_security_profile() { - let profile = ObjectSecurityProfile { - managed_by: Some("hr-system".into()), - permissions: vec!["read".into(), "write".into()], - trust_domain: Some("corp.com".into()), - data_scope: vec!["employee_data".into()], - }; - assert_eq!(profile.managed_by.as_deref(), Some("hr-system")); - assert_eq!(profile.permissions.len(), 2); - } - - #[test] - fn test_data_policy() { - let policy = DataPolicy { - apply_labels: vec!["PII".into()], - allowed_actions: Some(vec!["read".into()]), - denied_actions: vec!["delete".into()], - retention: Some(RetentionPolicy { - max_age_seconds: Some(86400), - policy: "30-day".into(), - delete_after: Some("2026-05-01".into()), - }), - }; - assert_eq!(policy.apply_labels[0], "PII"); - assert!(policy.retention.is_some()); - assert_eq!( - policy.retention.as_ref().unwrap().max_age_seconds, - Some(86400) - ); - } -} diff --git a/crates/cpex-core/src/extensions/tiers.rs b/crates/cpex-core/src/extensions/tiers.rs deleted file mode 100644 index d6e5ba40..00000000 --- a/crates/cpex-core/src/extensions/tiers.rs +++ /dev/null @@ -1,159 +0,0 @@ -// Location: ./crates/cpex-core/src/extensions/tiers.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Mutability tiers and capability definitions. -// -// Each extension slot has a mutability tier that controls how plugins -// can interact with it. Capabilities gate per-plugin access. -// -// Mirrors cpex/framework/extensions/tiers.py. - -use serde::{Deserialize, Serialize}; - -/// Mutability tier for an extension slot. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MutabilityTier { - /// Cannot be modified after creation. - Immutable, - /// Can only grow (add-only sets, append-only chains). - Monotonic, - /// Can be freely modified by plugins with write capability. - Mutable, -} - -/// Declared permission that controls extension access. -/// -/// # Why no `Write*` for identity slots -/// -/// The IdentityResolve and TokenDelegate hook families return result -/// payloads that the framework consumes to mutate `Extensions`. Plugins -/// never write to `security.subject` / `security.client` / -/// `security.*_workload` / `raw_credentials.*` directly — those slots -/// are owned by the framework on behalf of return-based handlers. The -/// matching write capabilities are therefore absent from this enum -/// until a use case appears for plugin-driven mutation of these slots -/// outside the resolve/delegate hooks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Capability { - /// Read the authenticated subject identity (`security.subject`). - /// Unlocks the slot but not its sub-fields — roles / teams / - /// claims / permissions each have their own cap below. - ReadSubject, - /// Read subject roles (`security.subject.roles`). - ReadRoles, - /// Read subject team memberships (`security.subject.teams`). - ReadTeams, - /// Read subject claims (`security.subject.claims`). - ReadClaims, - /// Read subject permissions (`security.subject.permissions`). - ReadPermissions, - - /// Read the OAuth client / gateway-access identity - /// (`security.client`). Distinct from the user identity - /// (`subject`) — a single user can connect through different - /// clients (first-party browser, third-party agent) and policies - /// sometimes want to gate on the client. - ReadClient, - - /// Read either workload-identity slot — both - /// `security.caller_workload` (the inbound attested peer) and - /// `security.this_workload` (our own outbound identity). One - /// capability covers both: a plugin either has access to - /// attested-workload identity or it doesn't. Distinct from - /// `read_agent` which governs session / conversation context, - /// **NOT** identity. - ReadWorkload, - - /// Read the agent execution context (`AgentExtension`). - /// **NOT a credential** — this carries session / conversation / - /// lineage state, not identity. Identity reads use - /// `read_subject` / `read_client` / `read_workload`. - ReadAgent, - - /// Read HTTP headers. - ReadHeaders, - /// Write (modify) HTTP headers. - WriteHeaders, - - /// Read security labels. - ReadLabels, - /// Append security labels (monotonic add-only). - AppendLabels, - - /// Read the delegation chain. - ReadDelegation, - /// Append to the delegation chain (monotonic). - AppendDelegation, - - /// Read raw inbound tokens - /// (`raw_credentials.inbound_tokens`) — the bearer-token - /// strings captured at the wire layer before validation. - /// Narrowly scoped: only IdentityResolve handlers, forwarding - /// plugins, and a small set of audit plugins should declare it. - /// - /// Token fields are `#[serde(skip)]`, so this capability never - /// yields token *bytes* over a generic serialized `Extensions` — - /// an out-of-process plugin reading the slot sees metadata with - /// empty token strings. It can still gate delivery of plaintext - /// out-of-process on a host's purpose-built side channel: see - /// `RawCredentialsExtension`'s module docs and - /// `cpex-hosts-python`'s `credential` DTO. - ReadInboundCredentials, - /// Read minted outbound delegated tokens - /// (`raw_credentials.delegated_tokens`) — the credentials a - /// TokenDelegate handler produced for an upstream call. Held by - /// forwarding / proxy plugins that re-attach them on the outbound - /// request. Same serialization and out-of-process rules as - /// `read_inbound_credentials`; the two are independently scoped and - /// neither unlocks the other. - ReadDelegatedTokens, -} - -/// Access policy for an extension slot. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AccessPolicy { - /// All plugins can access. - Unrestricted, - /// Only plugins with the declared capability can access. - CapabilityGated, -} - -/// Policy for a single extension slot. -/// -/// Declares the mutability tier, access policy, and required -/// capabilities for reading and writing. -#[derive(Debug, Clone)] -pub struct SlotPolicy { - /// How the slot can be modified. - pub tier: MutabilityTier, - /// Whether access requires a capability. - pub access: AccessPolicy, - /// Capability required for reading (if capability-gated). - pub read_cap: Option, - /// Capability required for writing (if capability-gated). - pub write_cap: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tier_serde() { - let tier = MutabilityTier::Monotonic; - let json = serde_json::to_string(&tier).unwrap(); - assert_eq!(json, "\"monotonic\""); - } - - #[test] - fn test_capability_serde() { - let cap = Capability::AppendLabels; - let json = serde_json::to_string(&cap).unwrap(); - assert_eq!(json, "\"append_labels\""); - } -} diff --git a/crates/cpex-core/src/factory.rs b/crates/cpex-core/src/factory.rs deleted file mode 100644 index fdef3059..00000000 --- a/crates/cpex-core/src/factory.rs +++ /dev/null @@ -1,137 +0,0 @@ -// Location: ./crates/cpex-core/src/factory.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Plugin factory registry. -// -// Provides a factory pattern for creating plugin instances from -// config. The host registers factories by `kind` name before -// loading config. When the manager processes a config file, it -// looks up the factory for each plugin's `kind` and calls create(). -// -// This decouples plugin instantiation from the manager — the -// manager doesn't know how to create a "builtin" vs "wasm" vs -// "python" plugin. The factory does. -// -// Mirrors the Python framework's PluginLoader in -// cpex/framework/loader/plugin.py. - -use std::collections::HashMap; -use std::sync::Arc; - -use crate::error::PluginError; -use crate::plugin::{Plugin, PluginConfig}; -use crate::registry::AnyHookHandler; - -/// Factory for creating plugin instances from config. -/// -/// The host registers factories by `kind` name before loading -/// config. When the manager processes a config file, it looks up -/// the factory for each plugin's `kind` and calls `create()`. -/// -/// The factory returns both the plugin and its handler because it -/// knows the concrete types — which handler traits the plugin -/// implements and which hooks it handles. -/// -/// # Examples -/// -/// ```rust,ignore -/// struct RateLimiterFactory; -/// -/// impl PluginFactory for RateLimiterFactory { -/// fn create(&self, config: &PluginConfig) -/// -> Result> -/// { -/// let plugin = Arc::new(RateLimiter::from_config(config)?); -/// let handler = Arc::new(TypedHandlerAdapter::::new( -/// Arc::clone(&plugin), -/// )); -/// Ok(PluginInstance { plugin, handler }) -/// } -/// } -/// -/// let mut factories = PluginFactoryRegistry::new(); -/// factories.register("security/rate_limit", Box::new(RateLimiterFactory)); -/// ``` -pub trait PluginFactory: Send + Sync { - /// Create a plugin instance and its handler from config. - /// - /// The `config` is the plugin's entry from the YAML file. - fn create(&self, config: &PluginConfig) -> Result>; -} - -/// A created plugin instance — the plugin and its type-erased handlers. -/// -/// Each handler is paired with the hook name it handles. A plugin -/// that implements multiple hook types (e.g., `ToolPreInvoke` and -/// `ToolPostInvoke`) returns one entry per hook. -pub struct PluginInstance { - /// The plugin implementation. - pub plugin: Arc, - - /// Type-erased handlers paired with their hook names. - /// Each entry maps a hook name to the adapter for that hook type. - pub handlers: Vec<(&'static str, Arc)>, -} - -/// Registry of plugin factories keyed by `kind` name. -/// -/// The host populates this before calling `PluginManager::from_config()`. -/// Each factory knows how to create plugins of a specific kind. -/// -/// # Examples -/// -/// ```rust,ignore -/// let mut factories = PluginFactoryRegistry::new(); -/// factories.register("builtin/rate_limit", Box::new(RateLimiterFactory)); -/// factories.register("builtin/identity", Box::new(IdentityFactory)); -/// -/// let manager = PluginManager::from_config(path, &factories)?; -/// ``` -pub struct PluginFactoryRegistry { - factories: HashMap>, -} - -impl PluginFactoryRegistry { - /// Create an empty factory registry. - pub fn new() -> Self { - Self { - factories: HashMap::new(), - } - } - - /// Register a factory for a given `kind` name. - /// - /// Registration is last-writer-wins: re-registering an existing `kind` - /// overrides it (this is intentional — a host can swap a builtin's impl). - /// Because silent override is a footgun, a warning is logged when an - /// existing registration is replaced. - pub fn register(&mut self, kind: impl Into, factory: Box) { - let kind = kind.into(); - if self.factories.insert(kind.clone(), factory).is_some() { - tracing::warn!(kind = %kind, "plugin factory overrides an existing registration"); - } - } - - /// Look up a factory by `kind` name. - pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory> { - self.factories.get(kind).map(|f| f.as_ref()) - } - - /// Whether a factory exists for the given `kind`. - pub fn has(&self, kind: &str) -> bool { - self.factories.contains_key(kind) - } - - /// All registered kind names. - pub fn kinds(&self) -> Vec<&str> { - self.factories.keys().map(|s| s.as_str()).collect() - } -} - -impl Default for PluginFactoryRegistry { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs deleted file mode 100644 index c2834725..00000000 --- a/crates/cpex-core/src/hooks/adapter.rs +++ /dev/null @@ -1,122 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/adapter.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// TypedHandlerAdapter — bridges typed HookHandler to the -// type-erased AnyHookHandler. -// -// This is framework plumbing that plugin authors never see. When a -// plugin is registered via `manager.register_handler::()`, the -// manager creates a TypedHandlerAdapter internally. The adapter -// translates between Box (what the executor passes) -// and the concrete payload type (what the handler expects), and awaits -// the typed handler's future before re-erasing the result. -// -// `HookHandler` is async-by-default (native AFIT). Plugins that -// don't await anything still write `async fn handle(...)`; the -// compiler emits a trivially-ready future that LLVM inlines, so the -// `.await` here is a no-op for sync-style plugins. - -use std::marker::PhantomData; -use std::sync::Arc; - -use crate::context::PluginContext; -use crate::error::PluginError; -use crate::executor::erase_result; -use crate::hooks::payload::{Extensions, PluginPayload}; -use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use crate::plugin::Plugin; -use crate::registry::AnyHookHandler; - -/// Adapts a typed `HookHandler` into the type-erased `AnyHookHandler` -/// interface used by the executor. -/// -/// Created automatically by `PluginManager::register_handler()`. Plugin -/// authors never instantiate this directly. -/// -/// `HookHandler` is async (native AFIT), so the adapter awaits the -/// returned future before re-erasing the result. Plugins that don't -/// `.await` anything compile to a ready future that LLVM inlines, so -/// they pay no observable cost over a plain function call. -/// -/// # Type Parameters -/// -/// - `H` — the hook type (implements `HookTypeDef`). -/// - `P` — the plugin type (implements `Plugin + HookHandler`). -pub struct TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ - /// The plugin instance. - plugin: Arc

, - - /// Phantom data to carry the hook type parameter. - _hook: PhantomData, -} - -impl TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ - /// Create a new adapter wrapping the given plugin. - pub fn new(plugin: Arc

) -> Self { - Self { - plugin, - _hook: PhantomData, - } - } -} - -#[async_trait::async_trait] -impl AnyHookHandler for TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ - /// Downcast the type-erased payload to the concrete type and call - /// the plugin's typed `handle()` method, awaiting the returned - /// future. - /// - /// The framework retains ownership of the payload — the handler - /// receives a borrow (`&H::Payload`) and clones only if it needs - /// to modify. The result is erased back to `ErasedResultFields` - /// for the executor. - /// - /// For plugins whose body contains no `.await`, the compiler emits - /// a trivially-ready future and LLVM inlines this `.await` to a - /// direct return — there is no observable runtime cost over a - /// plain function call. - async fn invoke( - &self, - payload: &dyn PluginPayload, - extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, Box> { - let typed_ref: &H::Payload = - payload - .as_any() - .downcast_ref::() - .ok_or_else(|| PluginError::Config { - message: format!( - "payload type mismatch for hook '{}': expected {}", - H::NAME, - std::any::type_name::() - ), - })?; - - let result = self.plugin.handle(typed_ref, extensions, ctx).await; - let plugin_result: PluginResult = result.into(); - - Ok(erase_result(plugin_result)) - } - - fn hook_type_name(&self) -> &'static str { - H::NAME - } -} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs deleted file mode 100644 index 80012acf..00000000 --- a/crates/cpex-core/src/hooks/macros.rs +++ /dev/null @@ -1,70 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/macros.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// define_hook! macro. -// -// Generates a HookTypeDef marker struct and trait implementation -// from a single declaration. This is the primary way to define new -// hooks — both built-in (CMF, tool, prompt) and custom (rate -// limiting, deployment gates, federation sync). -// -// Plugins implement the generic HookHandler trait (from -// trait_def.rs) for the generated marker struct. The handler -// receives a borrowed payload and returns the hook's result type. - -/// Generates a hook type definition and marker struct. -/// -/// # Usage -/// -/// ```rust,ignore -/// define_hook! { -/// /// Doc comment for the hook. -/// MyHook, "my_hook" => { -/// payload: MyPayload, -/// result: PluginResult, -/// } -/// } -/// ``` -/// -/// This generates a marker struct `MyHook` implementing `HookTypeDef`. -/// Plugins handle it by implementing `HookHandler`. -/// -/// # CMF Pattern (one handler, multiple hook names) -/// -/// For CMF hooks where one handler covers multiple hook names: -/// -/// ```rust,ignore -/// define_hook! { -/// /// CMF message evaluation hook. -/// CmfHook, "cmf" => { -/// payload: MessagePayload, -/// result: PluginResult, -/// } -/// } -/// -/// // Register the same handler for multiple names: -/// // manager.register_handler_for_names::(plugin, config, &[ -/// // "cmf.tool_pre_invoke", "cmf.llm_input", ... -/// // ]); -/// ``` -#[macro_export] -macro_rules! define_hook { - ( - $(#[$meta:meta])* - $name:ident, $hook_name:literal => { - payload: $payload:ty, - result: $result:ty $(,)? - } - ) => { - $(#[$meta])* - pub struct $name; - - impl $crate::hooks::trait_def::HookTypeDef for $name { - type Payload = $payload; - type Result = $result; - const NAME: &'static str = $hook_name; - } - }; -} diff --git a/crates/cpex-core/src/hooks/metadata.rs b/crates/cpex-core/src/hooks/metadata.rs deleted file mode 100644 index 952ef1b8..00000000 --- a/crates/cpex-core/src/hooks/metadata.rs +++ /dev/null @@ -1,395 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/metadata.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Hook routing metadata — answers "what dispatch context does this -// hook name belong to?" -// -// # What this solves -// -// cpex-core's `invoke_named::(hook_name, ...)` already routes to -// the right handlers based on the hook name. But APL's dispatcher -// (`apl-cpex/src/dispatch_plan.rs`) needs a finer-grained question: -// when a plugin is registered for MULTIPLE hooks (e.g. -// `[cmf.tool_pre_invoke, cmf.tool_post_invoke]`), which entry should -// fire for the current dispatch context? -// -// Pre-2026-05-25 dispatch_plan used a naming heuristic — any hook -// name containing "field", "redact", "scan", or "validate" was -// classified as field-context, everything else as step-context. Two -// problems: -// -// 1. **Multi-hook bug.** Two step-context hooks on the same plugin -// (pre + post) collapsed to "first non-field wins" — silent -// wrong dispatch when pre_invocation and post_invocation needed -// different entries. -// 2. **The "field-hook" classification didn't match any real hook.** -// No CMF hook actually carries `field` / `redact` / `scan` / -// `validate` in its name — the heuristic was anticipating a -// convention no plugin uses. APL's field-stage dispatch (from -// `args:` / `result:` pipelines) routes to the same hook a -// plugin registers under for step dispatch. -// -// This module replaces the heuristic with an explicit hook-name → -// metadata table. -// -// # The table -// -// Each entry maps a hook name to `HookMetadata`: -// -// * `entity_type` — `Some("tool")`, `Some("llm")`, etc. for hooks -// tied to an entity type; `None` for hook families that apply -// regardless of entity (`identity.resolve`, `token.delegate`). -// * `phase` — `Pre` / `Post` / `Unphased`. APL's evaluator uses -// this to pick the right entry for the current phase context. -// -// Lookup is the foundation for `apl-cpex::dispatch_plan`'s entry -// selection. -// -// # Phase semantics -// -// APL phases map to hook phases: -// -// * `args:` field stage → looks for `Pre` hooks -// * `pre_invocation:` step → looks for `Pre` hooks -// * `result:` field stage → looks for `Post` hooks -// * `post_invocation:` step → looks for `Post` hooks -// -// A plugin that wants to discriminate "args field stage" from -// "pre_invocation step" — both Pre context — inspects `PluginContext::hook_name()` -// itself. The hook-routing layer doesn't slice phase finer than -// Pre/Post. -// -// # Custom hook metadata -// -// Hosts and plugin authors can register metadata for custom hook -// names via [`register_hook_metadata`]. Unregistered hooks return -// [`HookMetadata::unknown`] from `lookup` — entity_type `None`, phase -// `Unphased`. That conservative default matches any dispatch context, -// so custom hooks dispatch on the first registered entry. Authors -// who want phase-aware behavior must register metadata explicitly. - -use std::collections::HashMap; -use std::sync::{OnceLock, RwLock}; - -use crate::cmf::constants::{ - ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT, - HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE, - HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE, - HOOK_CMF_TOOL_PRE_INVOKE, -}; -use crate::delegation::HOOK_TOKEN_DELEGATE; -use crate::identity::HOOK_IDENTITY_RESOLVE; - -/// Lifecycle position a hook occupies for dispatcher purposes. -/// -/// APL's args/pre_invocation phases dispatch to `Pre` hooks; APL's -/// result/post_invocation phases dispatch to `Post` hooks. Hook families -/// outside the request-lifecycle model (identity at request entry, -/// token-delegate inside authorization) use `Unphased` and match any -/// requested phase. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum HookPhase { - /// Pre-invocation hook — e.g. `cmf.tool_pre_invoke`, - /// `cmf.llm_input`. Dispatched from APL's `args:` field stages - /// and `pre_invocation:` steps. - Pre, - /// Post-invocation hook — e.g. `cmf.tool_post_invoke`, - /// `cmf.llm_output`. Dispatched from APL's `result:` field stages - /// and `post_invocation:` steps. - Post, - /// Not phase-bound. Covers hook families that fire once per - /// request without an APL phase concept (`identity.resolve`, - /// `token.delegate`) AND custom hooks the framework doesn't know - /// about. APL's dispatcher matches `Unphased` against any - /// requested phase — conservative default that lets unknown - /// hooks still dispatch. - Unphased, -} - -/// Metadata describing what dispatch context a hook name belongs to. -/// See module docs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HookMetadata { - /// Entity type the hook applies to (`"tool"`, `"llm"`, `"prompt"`, - /// `"resource"`). `None` means "applies regardless of entity_type" - /// — used for hooks that don't tie to MCP's entity-type taxonomy. - pub entity_type: Option<&'static str>, - /// Lifecycle phase the hook occupies. - pub phase: HookPhase, -} - -impl HookMetadata { - /// Default — `entity_type: None`, `phase: Unphased`. Used as - /// the fallback for hook names not in the registry. The - /// `matches` function treats `Unphased` as "matches any phase," - /// so unknown hooks dispatch on the first registered entry. - pub const fn unknown() -> Self { - Self { - entity_type: None, - phase: HookPhase::Unphased, - } - } - - /// Whether this hook's metadata matches a dispatch context. - /// - /// Matching rules: - /// - /// - `entity_type`: a hook tied to a specific entity_type - /// (`Some("tool")`) matches only contexts with that entity - /// type. A hook with `entity_type: None` matches any context. - /// A request without an entity_type (`None`) matches any hook - /// — the dispatcher hasn't specified what entity is in play, - /// so we can't filter on it. - /// - `phase`: exact match between hook's phase and the requested - /// phase, EXCEPT `Unphased` is a wildcard from either side - /// (lets custom / unregistered hooks dispatch without phase - /// rules). - pub fn matches(&self, request_entity_type: Option<&str>, requested_phase: HookPhase) -> bool { - let entity_ok = match (self.entity_type, request_entity_type) { - (Some(hook_et), Some(req_et)) => hook_et == req_et, - (Some(_), None) => true, // request didn't specify; don't filter - (None, _) => true, // hook applies to any entity_type - }; - if !entity_ok { - return false; - } - match (self.phase, requested_phase) { - (HookPhase::Unphased, _) | (_, HookPhase::Unphased) => true, - (a, b) => a == b, - } - } -} - -/// Built-in hook metadata. Plugin authors and hosts can register -/// additional entries via [`register_hook_metadata`]. The 8 CMF step -/// hooks (entity × pre/post) are the complete CMF-routable surface -/// today; identity + delegation are unphased. -const BUILTIN_METADATA: &[(&str, HookMetadata)] = &[ - // CMF tool - ( - HOOK_CMF_TOOL_PRE_INVOKE, - HookMetadata { - entity_type: Some(ENTITY_TOOL), - phase: HookPhase::Pre, - }, - ), - ( - HOOK_CMF_TOOL_POST_INVOKE, - HookMetadata { - entity_type: Some(ENTITY_TOOL), - phase: HookPhase::Post, - }, - ), - // CMF llm - ( - HOOK_CMF_LLM_INPUT, - HookMetadata { - entity_type: Some(ENTITY_LLM), - phase: HookPhase::Pre, - }, - ), - ( - HOOK_CMF_LLM_OUTPUT, - HookMetadata { - entity_type: Some(ENTITY_LLM), - phase: HookPhase::Post, - }, - ), - // CMF prompt - ( - HOOK_CMF_PROMPT_PRE_INVOKE, - HookMetadata { - entity_type: Some(ENTITY_PROMPT), - phase: HookPhase::Pre, - }, - ), - ( - HOOK_CMF_PROMPT_POST_INVOKE, - HookMetadata { - entity_type: Some(ENTITY_PROMPT), - phase: HookPhase::Post, - }, - ), - // CMF resource - ( - HOOK_CMF_RESOURCE_PRE_FETCH, - HookMetadata { - entity_type: Some(ENTITY_RESOURCE), - phase: HookPhase::Pre, - }, - ), - ( - HOOK_CMF_RESOURCE_POST_FETCH, - HookMetadata { - entity_type: Some(ENTITY_RESOURCE), - phase: HookPhase::Post, - }, - ), - // Non-CMF families (entity-agnostic, not phase-bound). - ( - HOOK_IDENTITY_RESOLVE, - HookMetadata { - entity_type: None, - phase: HookPhase::Unphased, - }, - ), - ( - HOOK_TOKEN_DELEGATE, - HookMetadata { - entity_type: None, - phase: HookPhase::Unphased, - }, - ), -]; - -/// Runtime-registered additions to the metadata table. Hosts / -/// plugin authors call [`register_hook_metadata`] to populate. -/// Initialized with the BUILTIN_METADATA on first access. -fn registry() -> &'static RwLock> { - static REGISTRY: OnceLock>> = OnceLock::new(); - REGISTRY.get_or_init(|| { - let mut map: HashMap = HashMap::new(); - for (name, meta) in BUILTIN_METADATA { - map.insert((*name).to_string(), *meta); - } - RwLock::new(map) - }) -} - -/// Look up metadata for a hook name. Returns -/// [`HookMetadata::unknown`] for names not in the registry — -/// equivalent to "no phase, no entity_type filter," which lets -/// unregistered hooks still dispatch via the conservative wildcard -/// in [`HookMetadata::matches`]. -pub fn lookup(hook_name: &str) -> HookMetadata { - let r = registry().read().unwrap_or_else(|p| p.into_inner()); - r.get(hook_name).copied().unwrap_or(HookMetadata::unknown()) -} - -/// Register or override metadata for a hook name. Idempotent — a -/// host re-registering the same hook with the same metadata is fine. -/// Re-registering with different metadata overwrites the previous -/// entry; intentional for hosts that need to customize defaults. -/// -/// Thread-safe; intended to be called at startup. Concurrent calls -/// are serialized via the registry's `RwLock`. -pub fn register_hook_metadata(hook_name: impl Into, meta: HookMetadata) { - let mut w = registry().write().unwrap_or_else(|p| p.into_inner()); - w.insert(hook_name.into(), meta); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cmf_tool_pre_invoke_is_pre_phase_for_tool_entity() { - let meta = lookup(HOOK_CMF_TOOL_PRE_INVOKE); - assert_eq!(meta.entity_type, Some(ENTITY_TOOL)); - assert_eq!(meta.phase, HookPhase::Pre); - } - - #[test] - fn cmf_llm_output_is_post_phase_for_llm_entity() { - let meta = lookup(HOOK_CMF_LLM_OUTPUT); - assert_eq!(meta.entity_type, Some(ENTITY_LLM)); - assert_eq!(meta.phase, HookPhase::Post); - } - - #[test] - fn identity_resolve_is_unphased_no_entity() { - let meta = lookup(HOOK_IDENTITY_RESOLVE); - assert_eq!(meta.entity_type, None); - assert_eq!(meta.phase, HookPhase::Unphased); - } - - #[test] - fn token_delegate_is_unphased_no_entity() { - let meta = lookup(HOOK_TOKEN_DELEGATE); - assert_eq!(meta.entity_type, None); - assert_eq!(meta.phase, HookPhase::Unphased); - } - - #[test] - fn unknown_hook_returns_universal_default() { - let meta = lookup("custom.unrecognized_hook"); - assert_eq!(meta.entity_type, None); - assert_eq!(meta.phase, HookPhase::Unphased); - } - - #[test] - fn matches_filters_by_entity_type_when_set() { - let tool_pre = HookMetadata { - entity_type: Some(ENTITY_TOOL), - phase: HookPhase::Pre, - }; - assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre)); - assert!(!tool_pre.matches(Some(ENTITY_LLM), HookPhase::Pre)); - } - - #[test] - fn matches_allows_any_entity_when_hook_entity_is_none() { - let universal = HookMetadata { - entity_type: None, - phase: HookPhase::Pre, - }; - assert!(universal.matches(Some(ENTITY_TOOL), HookPhase::Pre)); - assert!(universal.matches(Some(ENTITY_LLM), HookPhase::Pre)); - assert!(universal.matches(None, HookPhase::Pre)); - } - - #[test] - fn matches_phase_exactly_unless_unphased() { - let tool_pre = HookMetadata { - entity_type: Some(ENTITY_TOOL), - phase: HookPhase::Pre, - }; - assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre)); - assert!(!tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Post)); - } - - #[test] - fn matches_unphased_is_wildcard_in_either_direction() { - let unphased = HookMetadata { - entity_type: None, - phase: HookPhase::Unphased, - }; - assert!(unphased.matches(Some(ENTITY_TOOL), HookPhase::Pre)); - assert!(unphased.matches(Some(ENTITY_LLM), HookPhase::Post)); - - let tool_pre = HookMetadata { - entity_type: Some(ENTITY_TOOL), - phase: HookPhase::Pre, - }; - // Request with Unphased phase matches any registered hook - // of the right entity_type. - assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Unphased)); - } - - #[test] - fn matches_request_without_entity_type_doesnt_filter_on_it() { - let tool_pre = HookMetadata { - entity_type: Some(ENTITY_TOOL), - phase: HookPhase::Pre, - }; - // Request didn't specify entity_type — hook still matches. - assert!(tool_pre.matches(None, HookPhase::Pre)); - } - - #[test] - fn register_hook_metadata_overrides_default() { - let name = "test_custom.overridden_meta"; - register_hook_metadata( - name, - HookMetadata { - entity_type: Some("custom"), - phase: HookPhase::Pre, - }, - ); - let meta = lookup(name); - assert_eq!(meta.entity_type, Some("custom")); - assert_eq!(meta.phase, HookPhase::Pre); - } -} diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs deleted file mode 100644 index 8ad5115e..00000000 --- a/crates/cpex-core/src/hooks/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Hook system. -// -// Provides the core abstractions for defining and dispatching hooks: -// -// - [`HookTypeDef`] — marker trait associating a typed payload + result with a hook name. -// - [`PluginPayload`] — base trait for all hook payloads (mirrors Python's PluginPayload). -// - [`PluginResult`] — result type with separate payload and extension modifications. -// - [`Extensions`] — capability-gated extension view passed to handlers. -// - [`define_hook!`] — macro for declaring new hook types with handler traits. -// - [`hook_names`] / [`cmf_hook_names`] — string constants for built-in hooks. -// -// Hook types are open — hosts define their own using define_hook! alongside the built-ins. - -pub mod adapter; -pub mod macros; -pub mod metadata; -pub mod payload; -pub mod trait_def; -pub mod types; - -// Re-export core types at the hooks level -pub use adapter::TypedHandlerAdapter; -pub use metadata::{ - lookup as lookup_hook_metadata, register_hook_metadata, HookMetadata, HookPhase, -}; -pub use payload::{Extensions, PluginPayload}; -pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; -pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs deleted file mode 100644 index ef3e6950..00000000 --- a/crates/cpex-core/src/hooks/payload.rs +++ /dev/null @@ -1,125 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/payload.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// PluginPayload trait and Extensions stub. -// -// PluginPayload is the base trait for all hook payloads, mirroring -// Python's PluginPayload(BaseModel, frozen=True). All payloads in -// the framework implement this trait, giving the executor and -// registry a common bound for type safety. -// -// The trait is object-safe — the executor works with `Box` -// instead of `Box`, catching type errors at compile time. -// Downcasting to concrete types uses the `as_any()` method. -// -// Extensions is the typed container for all message extensions -// (security, delegation, HTTP, meta, etc.). It is always passed -// as a separate parameter to handlers — never inside the payload. -// This allows per-plugin capability filtering and independent -// modification without copying the payload. - -use std::any::Any; -use std::fmt; - -// Re-export Extensions and OwnedExtensions from the extensions module. -// These are the typed containers for all extension data. They live in -// extensions/container.rs but are re-exported here for backward -// compatibility with existing code that imports from hooks::payload. -pub use crate::extensions::{Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken}; - -/// Base trait for all hook payloads. -/// -/// Mirrors Python's `PluginPayload(BaseModel, frozen=True)`. Every -/// payload type in the framework implements this trait. The executor -/// and registry use `Box` (not `Box`) -/// for type-safe dispatch. -/// -/// The trait is **object-safe** — it can be used behind `Box`, `&`, -/// and `Arc` without knowing the concrete type. This is achieved by -/// providing `clone_boxed()` instead of requiring `Clone` directly -/// (which is not object-safe), and `as_any()` / `as_any_mut()` for -/// downcasting to the concrete type when needed. -/// -/// Payloads are: -/// - Cloneable via `clone_boxed()` — the executor uses this for COW -/// when a modifying plugin (Sequential or Transform) needs ownership. -/// - `Send + Sync` — payloads may be shared across threads for -/// Concurrent mode plugins. -/// - `'static` — payloads must be owned types (no borrowed references). -/// -/// Extensions are **not** part of the payload. They are passed as a -/// separate `&Extensions` parameter to handlers. -/// -/// # Examples -/// -/// ``` -/// use cpex_core::hooks::payload::PluginPayload; -/// -/// #[derive(Debug, Clone)] -/// struct RateLimitPayload { -/// client_id: String, -/// request_count: u64, -/// } -/// -/// impl PluginPayload for RateLimitPayload { -/// fn clone_boxed(&self) -> Box { -/// Box::new(self.clone()) -/// } -/// fn as_any(&self) -> &dyn std::any::Any { self } -/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self } -/// } -/// ``` -pub trait PluginPayload: Send + Sync + 'static { - /// Clone this payload into a new `Box`. - /// - /// Used by the executor for copy-on-write: read-only modes borrow - /// the payload, modifying modes receive a clone via this method. - fn clone_boxed(&self) -> Box; - - /// Downcast to a concrete type via `&dyn Any`. - /// - /// Used by typed handler wrappers to recover the concrete payload - /// type from `Box`. - fn as_any(&self) -> &dyn Any; - - /// Downcast to a concrete type via `&mut dyn Any`. - fn as_any_mut(&mut self) -> &mut dyn Any; -} - -impl fmt::Debug for dyn PluginPayload { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("dyn PluginPayload") - } -} - -/// Implements `PluginPayload` for a type that is `Clone + Send + Sync + 'static`. -/// -/// Saves boilerplate — instead of writing the three methods manually, -/// just invoke this macro: -/// -/// ``` -/// use cpex_core::impl_plugin_payload; -/// -/// #[derive(Debug, Clone)] -/// struct MyPayload { value: i32 } -/// -/// impl_plugin_payload!(MyPayload); -/// ``` -#[macro_export] -macro_rules! impl_plugin_payload { - ($ty:ty) => { - impl $crate::hooks::payload::PluginPayload for $ty { - fn clone_boxed(&self) -> Box { - Box::new(self.clone()) - } - fn as_any(&self) -> &dyn std::any::Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - } - }; -} diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs deleted file mode 100644 index cdd2cefc..00000000 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ /dev/null @@ -1,310 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/trait_def.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// HookTypeDef trait and PluginResult type. -// -// Every hook in the CPEX framework is defined by a marker type that -// implements HookTypeDef. This associates a typed PluginPayload and -// PluginResult with a string name used for registry lookup and config. -// -// The hook type does NOT declare an access pattern (read-only vs -// mutating). The plugin's mode (from PluginRef.trusted_config) -// determines scheduling and authority at runtime. Security invariants -// come from the types inside the payload (Arc, MonotonicSet, -// Guarded), not from borrow mechanics. -// -// Extensions are always a separate parameter — never part of the -// payload. This allows capability-filtered views per plugin and -// independent modification of extensions without copying the payload. - -use crate::context::PluginContext; -use crate::error::PluginViolation; -use crate::hooks::payload::{Extensions, PluginPayload}; -use crate::plugin::Plugin; - -/// Defines a hook's contract: what goes in and what comes out. -/// -/// Each hook type is a zero-sized marker struct that implements this -/// trait. The framework uses the associated types for compile-time -/// dispatch and the NAME constant for registry lookup. -/// -/// The hook type does **not** declare an access pattern. The plugin's -/// mode (from `PluginRef.trusted_config`) determines whether the -/// executor passes a borrow or a clone: -/// -/// | Mode | Receives | Can Block? | Can Modify? | -/// |-----------------|-----------------|------------|-------------| -/// | Sequential | owned (clone) | Yes | Yes | -/// | Transform | owned (clone) | No | Yes | -/// | Audit | &Payload | No | No | -/// | Concurrent | &Payload | Yes | No | -/// | FireAndForget | &Payload | No | No | -/// -/// # Defining a Hook -/// -/// Use the [`define_hook!`] macro instead of implementing this trait -/// manually — the macro generates the marker struct, the trait impl, -/// and the handler trait in one declaration. -pub trait HookTypeDef: Send + Sync + 'static { - /// The typed payload that handlers receive. - /// Must implement [`PluginPayload`] (Clone + Send + Sync + 'static). - type Payload: PluginPayload; - - /// The typed result that handlers return. - type Result: Send + Sync; - - /// Hook name — used as the registry key and in config YAML. - /// - /// Multiple hook names can map to the same HookTypeDef (the CMF - /// pattern where one handler covers `cmf.tool_pre_invoke`, - /// `cmf.llm_input`, etc.). The primary NAME is used for - /// single-name registration; additional names are registered - /// via `register_for_names()`. - const NAME: &'static str; -} - -/// Typed handler for a specific hook type. -/// -/// Plugin authors implement this trait (alongside [`Plugin`]) to handle -/// a specific hook. The type parameter `H` ties the handler to a -/// `HookTypeDef`, ensuring the correct payload and result types at -/// compile time. The framework creates a type-erased adapter internally -/// when you register — you never touch `AnyHookHandler` directly. -/// -/// # Async by design -/// -/// `handle` is an `async fn`. Plugins that don't need to `.await` -/// anything still write `async fn handle(...)` and return synchronously -/// — the compiler emits a trivially-ready future and LLVM inlines it -/// at the adapter site, so there's no observable runtime cost over a -/// plain function. Plugins that *do* need to `.await` (fresh JWKS -/// fetch, RPC to an authz service, dynamic policy lookup) just use -/// `.await` inside the body. -/// -/// **Best practice:** even when async is available, prefer pre-loading -/// state in [`Plugin::initialize`] and reading from cache in `handle`. -/// Hot-path I/O is the most common source of latency regressions. -/// -/// # Native AFIT, not `#[async_trait]` -/// -/// The trait uses native `async fn` (return-position `impl Future`) -/// rather than `#[async_trait]`. This avoids a per-call heap -/// allocation: the returned future is monomorphized into the -/// [`TypedHandlerAdapter`] rather than boxed. The trait is therefore -/// **not object-safe** — you cannot have `Box>`. -/// We don't need that; type erasure happens one layer up at -/// [`AnyHookHandler`]. -/// -/// # Examples -/// -/// ```rust,ignore -/// // Synchronous plugin — no .await, no extra cost -/// impl HookHandler for AllowPlugin { -/// async fn handle( -/// &self, -/// _payload: &MessagePayload, -/// _extensions: &Extensions, -/// _ctx: &mut PluginContext, -/// ) -> PluginResult { -/// PluginResult::allow() -/// } -/// } -/// -/// // Async plugin — calls .await inside the body -/// impl HookHandler for AuthzPlugin { -/// async fn handle( -/// &self, -/// payload: &MyPayload, -/// _extensions: &Extensions, -/// _ctx: &mut PluginContext, -/// ) -> PluginResult { -/// match self.client.check(&payload.user).await { -/// Ok(true) => PluginResult::allow(), -/// _ => PluginResult::deny(/* ... */), -/// } -/// } -/// } -/// -/// // Registration is the same for both: -/// manager.register_handler::(plugin, config)?; -/// ``` -/// -/// [`PluginManager::register_handler`]: crate::manager::PluginManager::register_handler -/// [`AnyHookHandler`]: crate::registry::AnyHookHandler -/// [`TypedHandlerAdapter`]: crate::hooks::adapter::TypedHandlerAdapter -pub trait HookHandler: Plugin + Send + Sync { - /// Handle the hook invocation. - /// - /// Receives a **borrow** of the typed payload, capability-filtered - /// extensions, and per-invocation context. Returns a typed result. - /// - /// The payload is immutable — Rust's borrow checker prevents - /// modification through `&H::Payload`. To modify, the plugin - /// must `clone()` the payload (or the fields it needs) and return - /// the modified copy in `PluginResult::modify_payload()`. This - /// pushes the clone cost to the plugin that actually needs it — - /// read-only plugins (validators, auditors) never pay for a copy. - /// - /// Returns a `Send`-able future so the executor can drive it from - /// any worker thread (including the concurrent-phase `JoinSet`). - /// `H::Result` is already `Send + Sync` per the `HookTypeDef` - /// bound, so the `Send` constraint comes for free for typical - /// handlers. - fn handle( - &self, - payload: &H::Payload, - extensions: &Extensions, - ctx: &mut PluginContext, - ) -> impl std::future::Future + Send; -} - -/// Result returned by a hook handler. -/// -/// Payload and extension modifications are **separate** — this is a -/// core design decision. Extension-only changes (add a label, set a -/// header) don't require copying the payload. The payload is only -/// present in `modified_payload` when message content actually changed. -/// -/// The executor interprets the result based on the plugin's mode: -/// - Sequential/Transform: `modified_payload` and `modified_extensions` are accepted. -/// - Audit/Concurrent/FireAndForget: modifications are discarded. -/// - Sequential/Concurrent: `continue_processing = false` halts the pipeline. -/// - Transform/Audit/FireAndForget: blocks are suppressed. -/// -/// Mirrors Python's `PluginResult[T]` with separate `modified_payload` -/// and `modified_extensions` fields. -/// -/// # Examples -/// -/// ``` -/// use cpex_core::hooks::{PluginPayload, PluginResult}; -/// use cpex_core::error::PluginViolation; -/// -/// // Define a simple payload -/// #[derive(Debug, Clone)] -/// struct TestPayload { value: i32 } -/// cpex_core::impl_plugin_payload!(TestPayload); -/// -/// // Allow — no changes -/// let result: PluginResult = PluginResult::allow(); -/// assert!(result.continue_processing); -/// assert!(result.modified_payload.is_none()); -/// -/// // Deny -/// let result: PluginResult = PluginResult::deny( -/// PluginViolation::new("forbidden", "not allowed") -/// ); -/// assert!(!result.continue_processing); -/// assert!(result.violation.is_some()); -/// ``` -#[derive(Debug)] -pub struct PluginResult { - /// Whether the pipeline should continue processing. - /// `false` halts the pipeline (deny). Only respected for - /// Sequential and Concurrent modes. - pub continue_processing: bool, - - /// Modified payload. `None` means no content modification. - /// Only accepted from Sequential and Transform mode plugins. - pub modified_payload: Option

, - - /// Modified extensions. `None` means no extension changes. - /// Return an `OwnedExtensions` from `extensions.cow_copy()`. - /// The executor validates (immutable unchanged, monotonic superset) - /// and merges back into the pipeline's `Extensions`. - pub modified_extensions: Option, - - /// Policy violation. Present when `continue_processing` is `false`. - pub violation: Option, - - /// Optional metadata from the plugin (telemetry, diagnostics). - /// Not used for scheduling or policy decisions. - pub metadata: Option, -} - -impl PluginResult

{ - /// Allow — payload continues unchanged, no extension changes. - pub fn allow() -> Self { - Self { - continue_processing: true, - modified_payload: None, - modified_extensions: None, - - violation: None, - metadata: None, - } - } - - /// Deny — pipeline halts with a violation. - pub fn deny(violation: PluginViolation) -> Self { - Self { - continue_processing: false, - modified_payload: None, - modified_extensions: None, - - violation: Some(violation), - metadata: None, - } - } - - /// Modify payload only — extensions unchanged. - pub fn modify_payload(payload: P) -> Self { - Self { - continue_processing: true, - modified_payload: Some(payload), - modified_extensions: None, - - violation: None, - metadata: None, - } - } - - /// Modify extensions only — payload unchanged. - /// Takes an `OwnedExtensions` from `extensions.cow_copy()`. - pub fn modify_extensions(extensions: crate::hooks::payload::OwnedExtensions) -> Self { - Self { - continue_processing: true, - modified_payload: None, - modified_extensions: Some(extensions), - - violation: None, - metadata: None, - } - } - - /// Modify both payload and extensions. - /// Takes an `OwnedExtensions` from `extensions.cow_copy()`. - pub fn modify(payload: P, extensions: crate::hooks::payload::OwnedExtensions) -> Self { - Self { - continue_processing: true, - modified_payload: Some(payload), - modified_extensions: Some(extensions), - - violation: None, - metadata: None, - } - } - - /// Whether this result represents a denial. - pub fn is_denied(&self) -> bool { - !self.continue_processing - } - - /// Whether this result carries a modified payload. - pub fn is_payload_modified(&self) -> bool { - self.modified_payload.is_some() - } - - /// Whether this result carries modified extensions. - pub fn is_extensions_modified(&self) -> bool { - self.modified_extensions.is_some() - } -} - -impl Default for PluginResult

{ - fn default() -> Self { - Self::allow() - } -} diff --git a/crates/cpex-core/src/hooks/types.rs b/crates/cpex-core/src/hooks/types.rs deleted file mode 100644 index 8bc2b3e1..00000000 --- a/crates/cpex-core/src/hooks/types.rs +++ /dev/null @@ -1,176 +0,0 @@ -// Location: ./crates/cpex-core/src/hooks/types.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Hook type definitions. -// -// Hook types are open strings — hosts define hook points appropriate -// to their execution lifecycle. This module provides a newtype wrapper -// for type safety and built-in constants for the common hook points. -// -// The framework does not prescribe a fixed set of hook points. Each -// host places `invoke_hook()` calls at sites appropriate to its -// processing pipeline. The constants below cover the standard -// MCP/CMF lifecycle but hosts may register additional types. - -use std::fmt; - -use serde::{Deserialize, Serialize}; - -/// A named hook point in the host's execution lifecycle. -/// -/// Wraps a string identifier. Hook types are open — hosts register -/// their own alongside the built-in constants. -/// -/// # Examples -/// -/// ``` -/// use cpex_core::hooks::HookType; -/// use cpex_core::hooks::types::hook_names; -/// -/// // Use a built-in name constant -/// let hook = HookType::new(hook_names::TOOL_PRE_INVOKE); -/// assert_eq!(hook.as_str(), "tool_pre_invoke"); -/// -/// // Define a custom hook -/// let custom = HookType::new("generation_pre_call"); -/// assert_eq!(custom.as_str(), "generation_pre_call"); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct HookType(String); - -impl HookType { - /// Create a new hook type from a string. - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - - /// Return the hook type as a string slice. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for HookType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl From<&str> for HookType { - fn from(s: &str) -> Self { - Self::new(s) - } -} - -impl From for HookType { - fn from(s: String) -> Self { - Self(s) - } -} - -/// Legacy hook names — typed payloads (ToolPreInvokePayload, etc.). -pub mod hook_names { - // Tool lifecycle - pub const TOOL_PRE_INVOKE: &str = "tool_pre_invoke"; - pub const TOOL_POST_INVOKE: &str = "tool_post_invoke"; - - // Prompt lifecycle - pub const PROMPT_PRE_FETCH: &str = "prompt_pre_fetch"; - pub const PROMPT_POST_FETCH: &str = "prompt_post_fetch"; - - // Resource lifecycle - pub const RESOURCE_PRE_FETCH: &str = "resource_pre_fetch"; - pub const RESOURCE_POST_FETCH: &str = "resource_post_fetch"; - - // Identity and delegation - pub const IDENTITY_RESOLVE: &str = "identity_resolve"; - pub const TOKEN_DELEGATE: &str = "token_delegate"; -} - -/// CMF hook names — MessagePayload wrapping a CMF Message. -/// The `cmf.` prefix lets legacy and CMF plugins coexist at the -/// same interception point. The gateway fires both at each event. -pub mod cmf_hook_names { - // Tool lifecycle - pub const TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; - pub const TOOL_POST_INVOKE: &str = "cmf.tool_post_invoke"; - - // LLM lifecycle (CMF only — no legacy equivalent) - pub const LLM_INPUT: &str = "cmf.llm_input"; - pub const LLM_OUTPUT: &str = "cmf.llm_output"; - - // Prompt lifecycle - pub const PROMPT_PRE_FETCH: &str = "cmf.prompt_pre_fetch"; - pub const PROMPT_POST_FETCH: &str = "cmf.prompt_post_fetch"; - - // Resource lifecycle - pub const RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; - pub const RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; -} - -/// Returns all built-in hook types with their canonical string values. -/// -/// Called once during PluginManager initialization to populate the -/// hook registry. Hosts add their own hook types after this. -pub fn builtin_hook_types() -> Vec { - vec![ - // Legacy (typed payloads) - HookType::new("tool_pre_invoke"), - HookType::new("tool_post_invoke"), - HookType::new("prompt_pre_fetch"), - HookType::new("prompt_post_fetch"), - HookType::new("resource_pre_fetch"), - HookType::new("resource_post_fetch"), - HookType::new("identity_resolve"), - HookType::new("token_delegate"), - // CMF (MessagePayload) - HookType::new("cmf.tool_pre_invoke"), - HookType::new("cmf.tool_post_invoke"), - HookType::new("cmf.llm_input"), - HookType::new("cmf.llm_output"), - HookType::new("cmf.prompt_pre_fetch"), - HookType::new("cmf.prompt_post_fetch"), - HookType::new("cmf.resource_pre_fetch"), - HookType::new("cmf.resource_post_fetch"), - ] -} - -/// Look up a hook type by name. Returns the canonical instance if -/// it matches a built-in, otherwise creates a new custom HookType. -pub fn hook_type_from_str(name: &str) -> HookType { - HookType::new(name) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hook_type_equality() { - let a = HookType::new("tool_pre_invoke"); - let b = HookType::new("tool_pre_invoke"); - assert_eq!(a, b); - } - - #[test] - fn test_hook_type_display() { - let h = HookType::new("cmf.llm_input"); - assert_eq!(h.to_string(), "cmf.llm_input"); - } - - #[test] - fn test_hook_type_from_str() { - let h: HookType = "custom_hook".into(); - assert_eq!(h.as_str(), "custom_hook"); - } - - #[test] - fn test_builtin_hook_types_count() { - let builtins = builtin_hook_types(); - // 8 legacy + 8 CMF - assert_eq!(builtins.len(), 16); - } -} diff --git a/crates/cpex-core/src/identity/hook.rs b/crates/cpex-core/src/identity/hook.rs deleted file mode 100644 index a1f4e94b..00000000 --- a/crates/cpex-core/src/identity/hook.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Location: ./crates/cpex-core/src/identity/hook.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `IdentityHook` — the `HookTypeDef` marker for the IdentityResolve -// hook family. Plugins implement `HookHandler`; the -// framework dispatches into them at request entry to populate -// `Extensions.security.subject` / `.client` / `.caller_workload` / -// `Extensions.raw_credentials` before any tool / resource / prompt -// hook runs. -// -// # Single hook name (for now) -// -// v0 registers under the single name `identity.resolve`. If a future -// slice introduces an `identity.validate` phase that uses the same -// payload + result shape (e.g. a post-resolve consistency check), -// it can share `IdentityHook` and register under `identity.validate` -// via the multi-name registration path — same pattern as CMF's -// `cmf.tool_pre_invoke` / `cmf.llm_input` / etc. sharing `CmfHook`. -// Phases with a different payload shape (e.g. TokenDelegate) get -// their own hook type rather than reusing this one. -// -// # Lifecycle -// -// This file defines the *types*. Lifecycle wiring — when the -// framework calls `invoke_named::(...)`, how results -// merge back into `Extensions` — lands elsewhere. - -use crate::hooks::trait_def::PluginResult; - -use super::payload::IdentityPayload; - -/// Primary hook name for IdentityResolve handlers. Used as the -/// registry key when a host registers the handler via the standard -/// `register_handler` path. -pub const HOOK_IDENTITY_RESOLVE: &str = "identity.resolve"; - -crate::define_hook! { - /// Identity-resolve hook. - /// - /// **Payload** ([`IdentityPayload`]) — unified input + accumulator. - /// The host populates the input fields (`raw_token`, `source`, - /// `headers`, ...) once at request entry and never touches them - /// again; handlers populate the output fields (`subject`, - /// `client`, `caller_workload`, `delegation`, `raw_credentials`, - /// `rejected`, ...) on clones of the running payload. Input - /// fields are private and read through accessors — handlers - /// cannot mutate them even on a clone, so the wire-layer input - /// is canonical across the whole chain. - /// - /// **Result** ([`PluginResult`][PluginResult]) — - /// the executor's standard envelope. `modified_payload` carries - /// the updated payload. `continue_processing = false` halts the - /// pipeline (set when the handler decides to reject). - /// - /// **Threading.** Sequential-phase semantics already thread - /// handler N's `modified_payload` into handler N+1's input, so - /// the chain's natural behavior is "each handler sees the prior - /// handler's contributions in the running payload." No bespoke - /// `resolve_identity` method on `PluginManager` — the standard - /// `invoke_named::(...)` does the right thing. - /// - /// **Handler signature:** - /// - /// ```rust,ignore - /// impl HookHandler for MyResolver { - /// async fn handle( - /// &self, - /// payload: &IdentityPayload, - /// _extensions: &Extensions, - /// _ctx: &mut PluginContext, - /// ) -> PluginResult { - /// // Validate the raw token, build the SubjectExtension. - /// let claims = self.validate(payload.raw_token()).await?; - /// let mut updated = payload.clone(); - /// updated.subject = Some(claims.into_subject()); - /// PluginResult::modify_payload(updated) - /// } - /// } - /// ``` - /// - /// Handlers that want to layer onto prior state without manually - /// preserving every untouched field reach for - /// [`IdentityPayload::merge`][merge]. - /// - /// **Registration:** `manager.register_handler::(plugin, config)` - /// against the hook name `"identity.resolve"`. Multiple handlers - /// may register; the framework runs them in priority order and - /// the Sequential-phase chain accumulates their contributions - /// into the running payload. - /// - /// [merge]: super::payload::IdentityPayload::merge - /// [PluginResult]: crate::hooks::trait_def::PluginResult - IdentityHook, "identity.resolve" => { - payload: IdentityPayload, - result: PluginResult, - } -} diff --git a/crates/cpex-core/src/identity/mod.rs b/crates/cpex-core/src/identity/mod.rs deleted file mode 100644 index aeba5308..00000000 --- a/crates/cpex-core/src/identity/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Location: ./crates/cpex-core/src/identity/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Identity hook family — IdentityResolve. -// -// Mirrors the cmf/ module layout: the hook marker + handler trait -// machinery (provided by cpex-core's generic hooks layer) plus the -// hook-specific payload + result types. Token-delegation lives in -// its own sibling module; the two hook families share -// nothing in terms of payloads so they get separate `HookTypeDef` -// markers. -// -// Scope: data shapes only — no executor wiring, no -// framework merge-into-Extensions logic, no APL integration. Those -// land later. - -pub mod hook; -pub mod payload; -pub mod route_config; - -pub use hook::{IdentityHook, HOOK_IDENTITY_RESOLVE}; -pub use payload::{IdentityPayload, TokenSource}; -pub use route_config::{RouteIdentityConfig, RouteIdentityStep}; diff --git a/crates/cpex-core/src/identity/payload.rs b/crates/cpex-core/src/identity/payload.rs deleted file mode 100644 index 4048f1d4..00000000 --- a/crates/cpex-core/src/identity/payload.rs +++ /dev/null @@ -1,451 +0,0 @@ -// Location: ./crates/cpex-core/src/identity/payload.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `IdentityPayload` — the unified state struct threaded through the -// IdentityResolve hook chain. Plays two roles in one type: -// -// * **Input** (private fields, read-only after construction) — -// `raw_token`, `source`, `source_header`, `headers`, `client_host`, -// `client_port`. Populated by the host once at request entry and -// never mutated by handlers. Privacy is enforced at the module -// boundary: external code reads through `pub fn raw_token() -> &str` -// etc. and has no setters or mutable field access, so even a -// `payload.clone()` followed by `clone.raw_token = ...` fails to -// compile. -// -// * **Accumulating output** (`pub` fields) — `subject`, `client`, -// `caller_workload`, `delegation`, `raw_credentials`, `rejected`, -// `reject_status`, `reject_reason`, `resolved_at`, `raw_claims`. -// Handlers clone the payload, populate the output fields they care -// about, and return the updated payload via -// `PluginResult::modify_payload`. Sequential-phase executor -// semantics thread plugin N's output into plugin N+1's input, -// producing a natural accumulator chain. -// -// # Why one struct instead of separate Payload + Result -// -// An earlier draft had `IdentityPayload` (input) and `IdentityResult` -// (output) as distinct types — the Python framework's split -// (`cpex/framework/hooks/identity.py`). That made the first handler -// awkward: it received an "empty IdentityResult" with no way to read -// the raw token without dropping back to `Extensions`. Folding the -// two types into one means handler N always has the inputs it needs -// (private getters) plus whatever previous handlers have already -// accumulated (read direct pub fields), and the hook signature stays -// uniform with everything else in the framework — `invoke_named::` -// with `PluginResult` on the way out. -// -// # Rejection model -// -// Handlers reject via `PluginResult::deny(PluginViolation::new(code, -// reason))` — the same path every other hook uses. The executor's -// `continue_processing = false` check halts the chain at the -// framework level, so no later handler can run and accidentally -// overwrite the decision. There is intentionally no `rejected` / -// `reject_status` / `reject_reason` flag on the payload itself — -// duplicating the rejection state in a `pub` field would let a -// later handler clone the payload, clear the flag, and quietly -// turn a 401 into a 200. The framework's existing halt machinery -// already does the right thing. -// -// Host-side HTTP mapping is conventional: `PluginViolation.code` -// is the resolution-specific identifier (`auth.expired`, -// `auth.audience_mismatch`, `auth.missing_scope`), and the host -// maps it to a status code (401 / 403 / etc.). Same pattern as -// CMF tool-pre-invoke denials. - -use std::collections::HashMap; -use std::sync::Arc; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; - -use crate::executor::PipelineResult; -use crate::extensions::{ - ClientExtension, DelegationExtension, Extensions, RawCredentialsExtension, SecurityExtension, - SubjectExtension, WorkloadIdentity, -}; -use crate::impl_plugin_payload; - -/// Where the raw credential was extracted from. Lets handlers -/// short-circuit on payloads they don't service (an mTLS-only -/// resolver ignores `Bearer` payloads). `Custom(String)` is the -/// escape hatch for bespoke wire formats. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TokenSource { - /// `Authorization: Bearer ` style. - Bearer, - /// `X-User-Token` style — explicit user-identity header alongside - /// a separate gateway-access token in `Authorization`. - UserToken, - /// mTLS — credential is the peer X.509 chain (surfaced via - /// `X-Forwarded-Client-Cert`). `raw_token` may be empty in this - /// case; the chain itself flows through `headers`. - Mtls, - /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims. - SpiffeJwtSvid, - /// API key in a header or query param. - ApiKey, - /// Operator-defined extraction path. - #[serde(untagged)] - Custom(String), -} - -impl Default for TokenSource { - fn default() -> Self { - TokenSource::Bearer - } -} - -/// State threaded through the IdentityResolve hook chain. -/// -/// See the module-level docs for the input/output split. In short: -/// **input fields are private** (set once via the constructor + -/// builders, never mutated), **output fields are `pub`** (handlers -/// populate them on clones and return the updated payload). -/// -/// Implements `PluginPayload` so it can flow through the executor's -/// existing Sequential-phase machinery — no bespoke plumbing. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IdentityPayload { - /// Raw credential bytes. Cleared on drop via `Zeroizing`. - /// `#[serde(skip)]` — never appears in serialized output. - #[serde(skip)] - raw_token: Zeroizing, - - /// Where the credential was extracted from. - source: TokenSource, - - /// HTTP header (or other wire-level slot) the token arrived in. - #[serde(default, skip_serializing_if = "Option::is_none")] - source_header: Option, - - /// Full request headers — escape hatch for custom auth flows. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - headers: HashMap, - - /// Client IP, when known. - #[serde(default, skip_serializing_if = "Option::is_none")] - client_host: Option, - - /// Client TCP port, when known. - #[serde(default, skip_serializing_if = "Option::is_none")] - client_port: Option, - - /// Resolved user identity. `None` until a handler populates it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, - - /// Resolved OAuth client / gateway-access identity. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client: Option, - - /// Resolved attested workload identity for the inbound peer. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub caller_workload: Option, - - /// Initial delegation chain parsed from `act` / equivalent claims. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option, - - /// Raw inbound tokens to stash in - /// `Extensions.raw_credentials.inbound_tokens` after the chain - /// completes (gated by `read_inbound_credentials` for consumers). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub raw_credentials: Option, - - /// Optional resolution timestamp. Audit-useful. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_at: Option>, - - /// Raw decoded token claims, when a handler wants to expose them - /// for audit/policy without elevating each claim to a typed - /// field. Mirrors the Python `raw_claims: dict[str, Any]`. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub raw_claims: HashMap, -} - -impl IdentityPayload { - /// Construct a payload with the required input fields populated. - /// The most common entry point — hosts call this once per request - /// before invoking the hook. Optional input slots - /// (`source_header`, `headers`, `client_host`, `client_port`) are - /// set via the `.with_*` builders below; output fields start as - /// `None` / `false` / empty and accumulate as handlers run. - pub fn new(raw_token: impl Into, source: TokenSource) -> Self { - Self { - raw_token: Zeroizing::new(raw_token.into()), - source, - source_header: None, - headers: HashMap::new(), - client_host: None, - client_port: None, - subject: None, - client: None, - caller_workload: None, - delegation: None, - raw_credentials: None, - resolved_at: None, - raw_claims: HashMap::new(), - } - } - - pub fn with_source_header(mut self, h: impl Into) -> Self { - self.source_header = Some(h.into()); - self - } - - pub fn with_headers(mut self, h: HashMap) -> Self { - self.headers = h; - self - } - - pub fn with_client_host(mut self, h: impl Into) -> Self { - self.client_host = Some(h.into()); - self - } - - pub fn with_client_port(mut self, port: u16) -> Self { - self.client_port = Some(port); - self - } - - /// The raw credential bytes. Borrowed — handlers cannot move - /// or replace the underlying `Zeroizing` through this - /// accessor. - pub fn raw_token(&self) -> &str { - &self.raw_token - } - - pub fn source(&self) -> &TokenSource { - &self.source - } - - pub fn source_header(&self) -> Option<&str> { - self.source_header.as_deref() - } - - pub fn headers(&self) -> &HashMap { - &self.headers - } - - pub fn client_host(&self) -> Option<&str> { - self.client_host.as_deref() - } - - pub fn client_port(&self) -> Option { - self.client_port - } - - /// Layer another payload's *output* fields onto this one's, - /// following "Some replaces None, last write wins per slot." - /// Input fields are not touched — the running payload's input - /// is canonical for the whole chain. - /// - /// Rejection is *not* a merged field — handlers reject via - /// `PluginResult::deny`, which halts the chain at the framework - /// level rather than being expressed as payload state. See the - /// module docs for the rationale. - pub fn merge(&mut self, other: IdentityPayload) { - if other.subject.is_some() { - self.subject = other.subject; - } - if other.client.is_some() { - self.client = other.client; - } - if other.caller_workload.is_some() { - self.caller_workload = other.caller_workload; - } - if other.delegation.is_some() { - self.delegation = other.delegation; - } - if other.raw_credentials.is_some() { - self.raw_credentials = other.raw_credentials; - } - if other.resolved_at.is_some() { - self.resolved_at = other.resolved_at; - } - for (k, v) in other.raw_claims { - self.raw_claims.insert(k, v); - } - } - - /// Pull the resolved `IdentityPayload` out of a `PipelineResult` - /// returned by `mgr.invoke_named::(...)`. Returns - /// `None` when the pipeline was denied (no `modified_payload`) - /// or when the result's payload wasn't an `IdentityPayload` — a - /// programmer error if the latter, since the executor produces - /// `modified_payload` typed per the hook's `HookTypeDef::Payload`. - /// - /// Clones the inner payload — the original `Box` - /// stays in the `PipelineResult` so callers can also inspect - /// `continue_processing`, `violation`, etc. - pub fn from_pipeline_result(result: &PipelineResult) -> Option { - result - .modified_payload - .as_ref() - .and_then(|p| p.as_any().downcast_ref::()) - .cloned() - } - - /// Apply this payload's resolved identity slots back into an - /// `Extensions` container. Returns a new `Extensions` ready to - /// hand to the next hook in the request lifecycle (`cmf.tool_pre_invoke`, - /// etc.) — downstream plugins read `security.subject` / - /// `security.client` / `security.caller_workload` / - /// `raw_credentials` etc. through the standard capability-gated - /// filter. - /// - /// Merging rules: - /// - /// - **`security.subject` / `.client` / `.caller_workload`** — - /// `Some` values on the payload overwrite the existing slot; - /// other security fields (labels, classification, this_workload, - /// auth_method, objects, data) are preserved from the input - /// Extensions. - /// - **`raw_credentials`** — replaced wholesale when populated on - /// the payload. Wholesale rather than merged because handlers - /// produce the complete set of inbound tokens for this request; - /// the host's pre-invoke Extensions wouldn't normally carry one. - /// - **`delegation`** — replaced wholesale when populated. - /// Initial chain from `act` claims in the inbound credential. - /// - /// Input fields on the payload (`raw_token`, `headers`, …) are - /// **not** copied into Extensions — they're the resolver's - /// internal workspace, not request-wide state. - pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { - let needs_security_update = - self.subject.is_some() || self.client.is_some() || self.caller_workload.is_some(); - - if needs_security_update { - // Clone-out the existing security extension (or default a - // fresh one) so we can write our identity slots while - // preserving labels / classification / etc. - let mut sec: SecurityExtension = ext - .security - .as_ref() - .map(|arc| (**arc).clone()) - .unwrap_or_default(); - if let Some(s) = &self.subject { - sec.subject = Some(s.clone()); - } - if let Some(c) = &self.client { - sec.client = Some(c.clone()); - } - if let Some(w) = &self.caller_workload { - sec.caller_workload = Some(w.clone()); - } - ext.security = Some(Arc::new(sec)); - } - - if let Some(rc) = &self.raw_credentials { - ext.raw_credentials = Some(Arc::new(rc.clone())); - } - - if let Some(d) = &self.delegation { - ext.delegation = Some(Arc::new(d.clone())); - } - - ext - } -} - -impl_plugin_payload!(IdentityPayload); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn raw_token_serializes_without_secret() { - let p = IdentityPayload::new("eyJhbGciOiJSUzI1NiJ9.payload.sig", TokenSource::Bearer); - let json = serde_json::to_string(&p).unwrap(); - assert!( - !json.contains("eyJhbGciOiJSUzI1NiJ9"), - "raw_token leaked into serialized form: {}", - json, - ); - assert!(json.contains("bearer")); - } - - #[test] - fn deserialize_yields_empty_raw_token() { - let json = r#"{"source":"bearer"}"#; - let p: IdentityPayload = serde_json::from_str(json).unwrap(); - assert_eq!(p.raw_token(), ""); - assert_eq!(p.source(), &TokenSource::Bearer); - } - - #[test] - fn token_source_custom_round_trips() { - let s = TokenSource::Custom("magic-link".into()); - let json = serde_json::to_string(&s).unwrap(); - let back: TokenSource = serde_json::from_str(&json).unwrap(); - assert_eq!(s, back); - } - - #[test] - fn input_builders_chain() { - let mut h = HashMap::new(); - h.insert("user-agent".to_string(), "curl/8.0".to_string()); - let p = IdentityPayload::new("tok", TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(h) - .with_client_host("10.0.0.1") - .with_client_port(443); - assert_eq!(p.raw_token(), "tok"); - assert_eq!(p.source_header(), Some("Authorization")); - assert_eq!(p.client_host(), Some("10.0.0.1")); - assert_eq!(p.client_port(), Some(443)); - assert_eq!( - p.headers().get("user-agent").map(String::as_str), - Some("curl/8.0") - ); - } - - #[test] - fn handler_can_populate_output_on_clone() { - // Exercises the typical handler pattern: clone the running - // payload, set the output fields the handler is responsible - // for, return the updated payload. Input fields survive - // the clone unchanged. - let original = IdentityPayload::new("eyJ.tok", TokenSource::Bearer); - let mut updated = original.clone(); - updated.subject = Some(SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }); - assert_eq!(updated.raw_token(), "eyJ.tok"); // input preserved - assert_eq!( - updated.subject.as_ref().unwrap().id.as_deref(), - Some("alice") - ); - // Original unchanged — the clone is a separate value. - assert!(original.subject.is_none()); - } - - #[test] - fn merge_overlays_some_onto_none() { - // Cross-handler chaining: handler 1 resolves the subject, - // handler 2 contributes the workload. Merged result carries - // both. - let mut base = IdentityPayload::new("tok", TokenSource::Bearer); - base.subject = Some(SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }); - let mut overlay = IdentityPayload::new("tok", TokenSource::Bearer); - overlay.caller_workload = Some(WorkloadIdentity { - spiffe_id: Some("spiffe://corp.com/inbound".into()), - ..Default::default() - }); - base.merge(overlay); - assert_eq!(base.subject.as_ref().unwrap().id.as_deref(), Some("alice")); - assert!(base.caller_workload.is_some()); - } -} diff --git a/crates/cpex-core/src/identity/route_config.rs b/crates/cpex-core/src/identity/route_config.rs deleted file mode 100644 index b4b2839f..00000000 --- a/crates/cpex-core/src/identity/route_config.rs +++ /dev/null @@ -1,200 +0,0 @@ -// Location: ./crates/cpex-core/src/identity/route_config.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Route-level identity configuration — the parsed shape of a -// route's `identity:` block in unified-config YAML. -// -// # Semantic note -// -// Identity binding is **hook-specific**: the `identity:` block -// binds plugins ONLY for the `identity.resolve` hook on this -// route, independent of whatever the route's `plugins:` block does. -// This matters because in APL-driven routes, the `plugins:` block -// has different meaning (it's a per-route config-override list, -// not a dispatch list — APL controls the dispatch). Identity -// needs its own binding mechanism so the meaning is unambiguous -// regardless of whether APL is annotating the route. -// -// # YAML shapes -// -// Two accepted forms parse to the same IR. The visitor / parser -// logic in `crate::config` discriminates them. -// -// ```yaml -// # List form — implicit additive, common case -// identity: -// - corp-jwt -// - spiffe-attestor -// -// # Object form — when the override flag is needed -// identity: -// replace_inherited: true -// steps: -// - legacy-basic-auth -// ``` -// -// Each step is either a bare plugin name (string) or a map with -// `name:` + optional `on_error:` / `config:`: -// -// ```yaml -// identity: -// - corp-jwt # bare name -// - name: spiffe-attestor # map form -// on_error: deny -// config: -// verify_attestation: strict -// ``` - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// A route's parsed `identity:` block. Drives dispatch of the -/// `identity.resolve` hook for the route. -/// -/// `None` on a `RouteEntry` means "no identity declared for this -/// route" — `invoke_named::` will return an empty -/// entry list when filtered for this route, and the host's -/// `IdentityPayload` flows through unchanged (no resolvers fire). -/// -/// Inheritance (deferred) walks `global → tags → route` -/// and merges each layer's `RouteIdentityConfig` based on -/// `replace_inherited`: when `false` (the default), the new layer's -/// steps append after the inherited ones; when `true`, the new -/// layer's steps replace the inherited list wholesale. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RouteIdentityConfig { - /// Ordered list of identity steps to run. Empty list is valid: - /// `identity: { replace_inherited: true, steps: [] }` is the - /// "explicitly opt out of inherited identity" knob. - pub steps: Vec, - - /// When true, this block replaces any inherited identity steps - /// instead of appending to them. Set via the object-form YAML - /// (`identity: { replace_inherited: true, steps: [...] }`). - /// The list-form YAML always produces `false`. - /// - /// Honored by the inheritance merge once that lands. Today the - /// flag is stored without exercising its merge semantics (no - /// inheritance to override yet at route level). - #[serde(default, skip_serializing_if = "is_false")] - pub replace_inherited: bool, -} - -/// One step in the identity-phase pipeline. Points at a plugin -/// registered under the `identity.resolve` hook, optionally with -/// a per-call config override and an `on_error` policy that -/// controls what happens when the step fails. -/// -/// # Cumulative stacking -/// -/// At runtime, every step in the block runs (subject to its own -/// `on_error`). Each step's resolved `IdentityPayload` accumulates -/// — handlers contribute orthogonal slots (JWT → `subject`; -/// SPIFFE → `caller_workload`; agent resolver → `agent`) so they -/// compose without collision in the common case. -/// -/// # On-error semantics -/// -/// - `None` or `Some("continue")` — soft failure: the step's -/// contribution is dropped, the next step runs, and any missing -/// extensions get caught later by `require(authenticated)` / -/// `require(workload.*)` in downstream policy. -/// - `Some("deny")` — hard requirement: a failure halts the -/// request with the plugin's violation code. -/// -/// Unknown strings parse as best-effort; future slices may -/// introduce typed enums. -/// -/// # Per-step config override -/// -/// `config_override` reuses the existing per-call override -/// pathway. When present, the framework's -/// `create_override_instance` builds a new plugin instance with -/// the merged config and dispatches into it for this route. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RouteIdentityStep { - /// Plugin name — must match an entry in the top-level - /// `plugins:` block that registers under `identity.resolve`. - pub name: String, - - /// Optional config override applied for this step only. - /// `None` means "use the plugin's configured defaults from the - /// `plugins:` declaration." Stored as `serde_json::Value` to - /// match the existing `create_override_instance` interface. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_override: Option, - - /// Per-step failure handling. See type-level docs. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_error: Option, - - /// Catch-all for any other fields a future schema version - /// adds (timeout, priority, condition, …) — preserved so the - /// parser doesn't reject configs targeting newer runtimes. - #[serde(default, flatten, skip_serializing_if = "HashMap::is_empty")] - pub extra: HashMap, -} - -impl RouteIdentityStep { - /// Convenience for tests / programmatic construction: build a - /// bare step that just names a plugin with no overrides. - pub fn bare(name: impl Into) -> Self { - Self { - name: name.into(), - ..Default::default() - } - } -} - -/// `#[serde(skip_serializing_if = "is_false")]` helper — keeps -/// the YAML round-trip clean by omitting the default `false`. -fn is_false(b: &bool) -> bool { - !*b -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bare_step_has_no_overrides() { - let s = RouteIdentityStep::bare("corp-jwt"); - assert_eq!(s.name, "corp-jwt"); - assert!(s.config_override.is_none()); - assert!(s.on_error.is_none()); - assert!(s.extra.is_empty()); - } - - #[test] - fn config_default_is_empty_additive() { - let c = RouteIdentityConfig::default(); - assert!(c.steps.is_empty()); - assert!(!c.replace_inherited); - } - - #[test] - fn serializes_without_default_replace_inherited() { - // `replace_inherited: false` should round-trip as absent — - // it's the default and clutters the YAML otherwise. - let c = RouteIdentityConfig { - steps: vec![RouteIdentityStep::bare("corp-jwt")], - replace_inherited: false, - }; - let yaml = serde_yaml::to_string(&c).unwrap(); - assert!(!yaml.contains("replace_inherited"), "got: {yaml}"); - assert!(yaml.contains("corp-jwt"), "got: {yaml}"); - } - - #[test] - fn serializes_with_explicit_replace_inherited() { - let c = RouteIdentityConfig { - steps: vec![RouteIdentityStep::bare("legacy-basic-auth")], - replace_inherited: true, - }; - let yaml = serde_yaml::to_string(&c).unwrap(); - assert!(yaml.contains("replace_inherited: true"), "got: {yaml}"); - } -} diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs deleted file mode 100644 index fae7bd25..00000000 --- a/crates/cpex-core/src/lib.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Location: ./crates/cpex-core/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CPEX Core library root. -// -// Pure Rust plugin runtime with no FFI, WASM, or PyO3 dependencies. -// Provides the PluginManager, 5-phase executor, hook registry, -// unified config parser, and all core types. -// -// # Modules -// -// - [`plugin`] — Plugin trait, PluginRef, PluginMetadata, PluginConfig -// - [`hooks`] — HookType (open string registry), payload/result traits -// - [`executor`] — 5-phase execution engine (sequential → transform → audit → concurrent → fire_and_forget) -// - [`manager`] — PluginManager lifecycle and hook dispatch -// - [`registry`] — PluginInstanceRegistry and HookRegistry -// - [`config`] — Unified YAML configuration parsing -// - [`factory`] — Plugin factory registry for config-driven instantiation -// - [`context`] — PluginContext (local_state + global_state) -// - [`cmf`] — ContextForge Message Format (Message, ContentPart, enums) -// - [`identity`] — IdentityResolve hook family (subject / client / -// workload resolution from raw credentials) -// - [`delegation`] — TokenDelegate hook family (outbound credential -// minting for downstream calls) -// - [`elicitation`] — Elicitation hook family (human-in-the-loop: -// approval, confirmation, step-up, …) -// - [`error`] — Error types, violations, and result types - -pub mod cmf; -pub mod config; -pub mod context; -pub mod delegation; -pub mod elicitation; -pub mod error; -pub mod executor; -pub mod extensions; -pub mod factory; -pub mod hooks; -pub mod identity; -pub mod manager; -pub mod plugin; -pub mod registry; -pub mod visitor; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs deleted file mode 100644 index da5bb66c..00000000 --- a/crates/cpex-core/src/manager.rs +++ /dev/null @@ -1,5684 +0,0 @@ -// Location: ./crates/cpex-core/src/manager.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor, Fred Araujo -// -// Plugin manager. -// -// Owns the plugin lifecycle (initialize, dispatch, shutdown) and -// the PluginRegistry. Provides two invoke paths: -// -// - `invoke::()` — typed dispatch for Rust callers. Zero-cost. -// The hook type is known at compile time; no registry lookup or -// downcast needed for the payload. -// -// - `invoke_by_name()` — dynamic dispatch for Python/Go/WASM callers. -// Hook name resolved from the registry; payload passed as -// Box. -// -// The manager reads plugin configs from the config loader and wraps -// each plugin in a PluginRef with the authoritative config. Plugins -// never provide their own config to the manager. Trust flows: -// config loader → manager → PluginRef → executor -// -// Mirrors the Python framework's PluginManager in -// cpex/framework/manager.py. - -use std::hash::{Hash, Hasher}; -use std::path::Path; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; - -use hashbrown::HashMap; -use tracing::{error, info, warn}; - -use crate::config::{self, CpexConfig}; -use crate::context::PluginContextTable; -use crate::error::PluginError; -use crate::executor::{BackgroundTasks, Executor, ExecutorConfig, PipelineResult}; -use crate::factory::PluginFactoryRegistry; -use crate::hooks::adapter::TypedHandlerAdapter; -use crate::hooks::payload::{Extensions, PluginPayload}; -use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use crate::hooks::HookType; -use crate::plugin::{Plugin, PluginConfig}; -use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry}; - -/// Default upper bound on the routing cache. Caps memory growth from -/// attacker-controlled entity names without forcing operators to tune. -pub const DEFAULT_ROUTE_CACHE_MAX_ENTRIES: usize = 10_000; - -/// Configuration for the PluginManager. -#[derive(Debug, Clone)] -pub struct ManagerConfig { - /// Executor configuration (timeout, short-circuit behavior). - pub executor: ExecutorConfig, - - /// Maximum number of entries in the routing cache. When the cache - /// reaches this size, further inserts are rejected (with a one-shot - /// warn log) and resolutions fall back to the slow path. See - /// `PluginSettings::route_cache_max_entries` for the YAML surface. - pub route_cache_max_entries: usize, -} - -impl Default for ManagerConfig { - fn default() -> Self { - Self { - executor: ExecutorConfig::default(), - route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, - } - } -} - -/// Central plugin lifecycle and dispatch manager. -/// -/// Owns the plugin registry and executor. Provides the public API -/// that host systems (ContextForge, Kagenti, etc.) call to register -/// plugins and invoke hooks. -/// -/// # Lifecycle -/// -/// ```text -/// new() → register plugins → initialize() → invoke hooks → shutdown() -/// ``` -/// -/// # Two Invoke Paths -/// -/// - **`invoke::()`** — typed dispatch. The hook type `H` is known -/// at compile time. Payload type-checked at compile time. Used by -/// Rust callers. -/// -/// - **`invoke_by_name()`** — dynamic dispatch. The hook name is a -/// string. Payload is `Box`. Used by Python/Go/WASM -/// callers via the FFI or PyO3 bindings. -/// -/// Both paths use the same registry, executor, and 5-phase pipeline. -/// -/// # Trust Model -/// -/// The manager wraps each plugin in a `PluginRef` with an authoritative -/// config from the config loader. The executor reads all scheduling -/// decisions from `PluginRef.trusted_config` — never from the plugin. -/// Cache key for resolved routing entries. -/// -/// Includes entity type, name, hook name, and scope so that -/// the same tool on different scopes or at different hook points -/// caches separately. -/// -/// Custom Hash/Eq implementations hash on `&str` slices so that -/// `raw_entry` lookups with borrowed strings produce the same hash -/// as the owned key — enabling zero-allocation cache hits. -#[derive(Debug, Clone)] -struct RouteCacheKey { - entity_type: String, - entity_name: String, - hook_name: String, - scope: Option, -} - -impl Hash for RouteCacheKey { - fn hash(&self, state: &mut H) { - self.entity_type.as_str().hash(state); - self.entity_name.as_str().hash(state); - self.hook_name.as_str().hash(state); - self.scope.as_deref().hash(state); - } -} - -impl PartialEq for RouteCacheKey { - fn eq(&self, other: &Self) -> bool { - self.entity_type == other.entity_type - && self.entity_name == other.entity_name - && self.hook_name == other.hook_name - && self.scope == other.scope - } -} - -impl Eq for RouteCacheKey {} - -/// Mutable runtime state held atomically swappable behind `ArcSwap`. -/// -/// Every read on the hot path (`invoke_*`) does a single atomic load to -/// get an `Arc` — no locks. Mutating operations -/// (`register_*`, `load_config`, `unregister`) clone the current snapshot, -/// mutate the clone, and atomically swap the new `Arc` in. Old readers -/// finish on the old snapshot; new readers see the new one. This is the -/// classic Read-Copy-Update / RCU pattern: lock-free reads, copy-on-write -/// writes, no reader-writer contention. -/// -/// Cloning `PluginRegistry` is cheap because every value inside (`PluginRef`, -/// `AnyHookHandler`) is `Arc`-counted — only the `HashMap` shells duplicate. -#[derive(Clone)] -struct RuntimeSnapshot { - /// Plugin registry — stores PluginRefs and hook-to-handler mappings. - registry: PluginRegistry, - - /// Executor — stateless 5-phase pipeline engine. - executor: Executor, - - /// Parsed CPEX config (when loaded from file). Used for route resolution. - cpex_config: Option, - - /// Maximum number of entries the route cache will hold. Once reached, - /// new resolutions are computed normally but not memoized (reject-on-full). - route_cache_max_entries: usize, - - /// Per-route, per-hook handler overrides keyed by - /// `(entity_type, entity_name, scope, hook_name)`. When a request matches - /// an annotation, route resolution short-circuits to a single-entry list - /// containing the annotated handler instead of resolving the route's - /// imperative `plugins:` chain. - /// - /// Per-hook keying lets an orchestrator install distinct handlers for - /// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` on the same route — - /// useful when the pre/post phases need different handler state (e.g. - /// apl-cpex's `AplRouteHandler` binds each instance to either - /// `evaluate_pre` or `evaluate_post`). - /// - /// `scope` (None vs `Some("virtual-server-A")`) lets two virtual - /// servers / gateways with the same tool name carry distinct - /// orchestrators. Matching mirrors cpex-core's existing - /// `find_matching_route` semantics: a scoped request first tries the - /// exact `(et, en, Some(req_scope), hook)` annotation; on miss it falls - /// back to the unscoped `(et, en, None, hook)` default. An unscoped - /// request only matches `(et, en, None, hook)`. Net effect: None-scope - /// annotations act as a global default, scoped annotations override - /// per-scope. - /// - /// The plugins listed under the matching route are *still* registered - /// in the registry — they remain discoverable via `find_plugin_entries` - /// so the annotated handler can dispatch into them by-name (this is - /// what apl-cpex's `AplRouteHandler` does via `CmfPluginInvoker` for - /// `plugin(name)` references inside APL rules). - route_annotations: HashMap, -} - -/// Composite key for route annotations. Includes the hook name so a single -/// route can carry distinct handlers per phase (e.g. pre-invoke vs -/// post-invoke). -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -struct AnnotationKey { - entity_type: String, - entity_name: String, - scope: Option, - hook_name: String, -} - -pub struct PluginManager { - /// Hot-path runtime state. Swapped atomically on registration / config - /// reload — readers see a consistent view via a single `load_full()`. - runtime: arc_swap::ArcSwap, - - /// Factory registry — owned by the manager. Used for initial - /// instantiation and for creating override instances when routes - /// override a plugin's base config. - /// - /// Held in a `RwLock` rather than the `ArcSwap` snapshot because - /// `Box` is not `Clone`. Read on the slow path - /// (route cache miss + override config); write on `register_factory`. - /// The hot path never touches it. - factories: RwLock, - - /// Cache of resolved hook entries per (entity, hook, scope). - /// Populated on first access, invalidated on config reload. - /// Uses Arc so cache reads are refcount bumps (~1ns), not data copies. - route_cache: RwLock>>>, - - /// Hasher builder for zero-allocation cache lookups via raw_entry. - cache_hasher: hashbrown::DefaultHashBuilder, - - /// Set to true after the first time the cache rejects an insert in a - /// given fill cycle, so the warn log fires once per cycle rather than - /// on every miss under DoS. Reset by `clear_routing_cache()`. - route_cache_full_warned: AtomicBool, - - /// Whether initialize() has been called. Atomic so lifecycle methods - /// can be `&self` and the manager itself can sit behind `Arc`. - initialized: AtomicBool, - - /// Monotonic config-generation counter. Bumped every time the runtime - /// snapshot is swapped (factory mutation, config (re)load, plugin - /// register/unregister). External orchestrators (apl-cpex's dispatch - /// plan cache) pair their cached values with the generation seen at - /// build time; a generation mismatch on lookup signals "evict + rebuild." - /// Starts at 0; first snapshot publish (empty registry) leaves it at 0, - /// so callers can use 0 as a "never observed" sentinel. - generation: AtomicU64, - - /// Tracks in-flight fire-and-forget background tasks across all - /// invocations so `shutdown()` can wait for them to drain before - /// returning. Without this, audit/telemetry tasks spawned by recent - /// invokes get cancelled when the runtime tears down. Tasks are - /// `tracker.spawn`'d in `spawn_fire_and_forget`; `shutdown()` calls - /// `close().wait().await`. - /// - /// `TaskTracker` is internally `Arc`'d, so cloning is a refcount bump. - task_tracker: tokio_util::task::TaskTracker, - - /// External orchestrators registered via `register_visitor`. Walked - /// in registration order during `load_config_yaml` (after plugin - /// instantiation) so each visitor can inspect raw YAML sections and - /// install handlers via `annotate_route`. Empty by default — the - /// `load_config(CpexConfig)` path skips visitors entirely. - visitors: RwLock>>, -} - -/// Emit warnings for YAML settings that the runtime doesn't currently -/// honor. Called once per `load_config` / `from_config` so operators -/// who set these knobs aren't silently ignored. -/// -/// `user_patterns` / `content_types` on `PluginCondition` are not warned -/// — they were wired up alongside this fix and now actually filter. -fn warn_on_inactive_settings(cfg: &CpexConfig) { - if !cfg.plugin_dirs.is_empty() { - warn!( - "config sets `plugin_dirs` (count={}) but the runtime does not \ - scan directories for plugins — plugins must be registered via \ - `register_factory()` and listed under `plugins:`. Setting ignored.", - cfg.plugin_dirs.len(), - ); - } - if cfg.plugin_settings.parallel_execution_within_band { - warn!( - "config sets `plugin_settings.parallel_execution_within_band: true` \ - but the runtime does not honor it — use `mode: concurrent` on \ - individual plugins for parallel execution. Setting ignored.", - ); - } - if cfg.plugin_settings.fail_on_plugin_error { - warn!( - "config sets `plugin_settings.fail_on_plugin_error: true` but the \ - runtime does not honor it — use per-plugin `on_error: fail` for \ - that behavior. Setting ignored.", - ); - } -} - -/// Instantiate every plugin in `plugin_configs` via the matching factory -/// and register the resulting handlers into `target_registry`. Shared by -/// `PluginManager::from_config` (fresh registry) and `load_config` (clone -/// of the existing registry) so the instantiation loop lives in one place. -/// -/// Returns on the first failure (factory missing, factory.create error, or -/// duplicate-name registration). On error, `target_registry` is in a -/// partial state — both callers discard it on failure (load_config builds -/// the new registry on a clone and only swaps on Ok; from_config bails -/// before publishing the snapshot). -fn instantiate_plugins_into( - target_registry: &mut PluginRegistry, - plugin_configs: &[crate::plugin::PluginConfig], - factories: &PluginFactoryRegistry, -) -> Result<(), Box> { - for plugin_config in plugin_configs { - let factory = factories - .get(&plugin_config.kind) - .ok_or_else(|| PluginError::Config { - message: format!( - "no factory registered for plugin kind '{}' (plugin '{}')", - plugin_config.kind, plugin_config.name - ), - })?; - - let instance = factory.create(plugin_config)?; - - target_registry - .register_multi_handler(instance.plugin, plugin_config.clone(), instance.handlers) - .map_err(|msg| Box::new(PluginError::Config { message: msg }))?; - - info!( - "Registered plugin '{}' (kind: '{}') for hooks: {:?}", - plugin_config.name, plugin_config.kind, plugin_config.hooks - ); - } - Ok(()) -} - -/// Build a `RuntimeSnapshot` from a populated registry plus the YAML -/// settings on `cpex_config`. Pulls executor timeout / short-circuit and -/// the route-cache cap from `plugin_settings` so both registration paths -/// agree on field-by-field translation. -fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> RuntimeSnapshot { - let executor = Executor::new(ExecutorConfig { - timeout_seconds: cpex_config.plugin_settings.plugin_timeout, - short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, - }); - let route_cache_max_entries = cpex_config.plugin_settings.route_cache_max_entries; - RuntimeSnapshot { - registry, - executor, - cpex_config: Some(cpex_config), - route_cache_max_entries, - route_annotations: HashMap::new(), - } -} - -impl PluginManager { - /// Create a new PluginManager with the given configuration. - pub fn new(config: ManagerConfig) -> Self { - let cache_hasher = hashbrown::DefaultHashBuilder::default(); - let snapshot = RuntimeSnapshot { - registry: PluginRegistry::new(), - executor: Executor::new(config.executor), - cpex_config: None, - route_cache_max_entries: config.route_cache_max_entries, - route_annotations: HashMap::new(), - }; - Self { - runtime: arc_swap::ArcSwap::from_pointee(snapshot), - factories: RwLock::new(PluginFactoryRegistry::new()), - route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())), - cache_hasher, - route_cache_full_warned: AtomicBool::new(false), - initialized: AtomicBool::new(false), - generation: AtomicU64::new(0), - task_tracker: tokio_util::task::TaskTracker::new(), - visitors: RwLock::new(Vec::new()), - } - } - - /// Load the current runtime snapshot (lock-free, single atomic op). - fn load_runtime(&self) -> Arc { - self.runtime.load_full() - } - - /// Apply a mutation to the runtime snapshot via copy-on-write. - /// Clones the current snapshot, runs the closure on the clone, and - /// atomically swaps it in. Concurrent readers continue using the old - /// snapshot; subsequent readers see the new one. - fn mutate_runtime(&self, f: F) -> R - where - F: FnOnce(&mut RuntimeSnapshot) -> R, - { - let current = self.runtime.load_full(); - let mut next = (*current).clone(); - let result = f(&mut next); - self.runtime.store(Arc::new(next)); - // Release ordering pairs with the Acquire load in - // config_generation() — external cache consumers that observe a - // higher generation are guaranteed to see the new snapshot. - self.generation.fetch_add(1, Ordering::Release); - result - } - - /// Like `mutate_runtime` but the mutation can fail — the new snapshot - /// is only published on `Ok`. On `Err`, the original snapshot is - /// untouched, so a partially-mutated clone is silently discarded. - fn try_mutate_runtime(&self, f: F) -> Result - where - F: FnOnce(&mut RuntimeSnapshot) -> Result, - { - let current = self.runtime.load_full(); - let mut next = (*current).clone(); - let result = f(&mut next)?; - self.runtime.store(Arc::new(next)); - // Same Release-ordered bump as mutate_runtime — only on Ok, since - // Err leaves the snapshot untouched. - self.generation.fetch_add(1, Ordering::Release); - Ok(result) - } - - /// Monotonic counter that increments on every runtime snapshot swap - /// (registry mutation, config (re)load). External orchestrators - /// (e.g. apl-cpex's dispatch-plan cache) pair their cached values - /// with the generation seen at build time; a mismatch on lookup - /// signals "evict + rebuild." `Acquire` pairs with the `Release` - /// fetch_add in `mutate_runtime` / `try_mutate_runtime` so observing - /// a higher generation guarantees visibility of the new snapshot. - pub fn config_generation(&self) -> u64 { - self.generation.load(Ordering::Acquire) - } - - /// Register a plugin factory for a given `kind` name. - /// - /// The host calls this to tell the manager how to create plugins - /// of a specific kind. Must be called before `load_config()`. - /// - /// # Examples - /// - /// ```rust,ignore - /// let mut manager = PluginManager::default(); - /// manager.register_factory("builtin", Box::new(BuiltinFactory)); - /// manager.register_factory("security/rate_limit", Box::new(RateLimiterFactory)); - /// manager.load_config(Path::new("plugins.yaml"))?; - /// ``` - pub fn register_factory( - &self, - kind: impl Into, - factory: Box, - ) { - self.factories - .write() - .unwrap_or_else(|p| p.into_inner()) - .register(kind, factory); - } - - /// Load plugins from a YAML config file. - /// - /// Parses the config, looks up each plugin's `kind` in the - /// factory registry, instantiates the plugins, and registers - /// them. Factories must be registered via `register_factory()` - /// before calling this method. - /// - /// # Examples - /// - /// ```rust,ignore - /// let mut manager = PluginManager::default(); - /// manager.register_factory("builtin", Box::new(BuiltinFactory)); - /// manager.load_config_file(Path::new("plugins/config.yaml"))?; - /// manager.initialize().await?; - /// ``` - pub fn load_config_file(&self, path: &Path) -> Result<(), Box> { - let cpex_config = config::load_config(path)?; - self.load_config(cpex_config) - } - - /// Load plugins from a parsed config. - /// - /// Looks up each plugin's `kind` in the factory registry, - /// instantiates the plugins, and registers them with their - /// hook names from the config. - pub fn load_config(&self, cpex_config: CpexConfig) -> Result<(), Box> { - warn_on_inactive_settings(&cpex_config); - - // Build the new snapshot from the current one — copy-on-write so - // concurrent invokes keep using the existing config until we swap. - // We can't use mutate_runtime here because we need to atomically - // ALSO build a new executor + new cache cap from the same config — - // the snapshot fields are coupled. - let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); - let current = self.runtime.load_full(); - let mut new_registry = current.registry.clone(); - - instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, &factories)?; - - // Drop the factories read lock before taking other locks - // (route_cache write below) to avoid lock-ordering hazards. - drop(factories); - - self.runtime - .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); - // Same generation bump as mutate_runtime — load_config doesn't - // go through that helper because it has to swap registry + executor - // + cache-cap atomically as one snapshot. - self.generation.fetch_add(1, Ordering::Release); - - // Clear routing cache — config changed. - self.clear_routing_cache(); - - Ok(()) - } - - /// Register an external config visitor. Visitors run during - /// `load_config_yaml` (after plugin instantiation) and can install - /// per-route handler overrides via `annotate_route`. Visitor order - /// matches registration order. Multiple visitors are allowed — - /// they typically don't share state, so order rarely matters. - pub fn register_visitor(&self, visitor: Arc) { - let mut v = self.visitors.write().unwrap_or_else(|p| p.into_inner()); - v.push(visitor); - } - - /// Load a unified-config YAML string. Parses the YAML twice — once - /// into a typed `CpexConfig` for plugin instantiation, once into a - /// raw `serde_yaml::Value` so visitors can inspect orchestrator- - /// specific blocks (e.g. `apl:`) that cpex-core itself doesn't - /// model. Calls existing `load_config(cpex_config)` first, then - /// walks each registered visitor over the raw YAML's sections in - /// the documented hierarchy order: - /// - /// 1. `visit_global(global_yaml)` - /// 2. `visit_default(entity_type, default_yaml)` per `global.defaults` entry - /// 3. `visit_policy_bundle(tag, bundle_yaml)` per `global.policies` entry - /// 4. `visit_route(route_yaml, parsed_route)` per `routes[]` entry - /// - /// All sections for one visitor run before the next visitor starts, - /// giving each visitor a consistent view of its own accumulated - /// state. A visitor returning Err aborts the load — the plugin - /// snapshot stays at the post-`load_config` state (partial load is - /// not rolled back; operators should treat any error from this - /// method as a hard stop). - pub fn load_config_yaml(self: &Arc, yaml: &str) -> Result<(), Box> { - // Parse once into a Value so the raw shape is available to - // visitors. Then deserialize from that Value into CpexConfig — - // saves a second tokenize/lex pass vs parsing the string twice. - let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| { - Box::new(PluginError::Config { - message: format!("YAML parse error: {}", e), - }) - })?; - let mut cpex_config: CpexConfig = serde_yaml::from_value(raw.clone()).map_err(|e| { - Box::new(PluginError::Config { - message: format!("CpexConfig deserialize error: {}", e), - }) - })?; - - // Normalize + validate on the SAME path `parse_config` uses. A bare - // deserialize does none of this, so without it a running host never - // folds top-level `groups:` into `global.policies` (routes lose the - // group's plugins + `authentication:`), never rejects the renamed - // `identity:` key, and never validates references. - crate::config::reject_renamed_identity_key(&raw)?; - crate::config::merge_groups_into_policies(&mut cpex_config); - crate::config::validate_config(&cpex_config)?; - - // Snapshot the parsed routes + plugin declarations before - // load_config moves the config — visitors get the typed - // structures side-by-side with the raw YAML so they don't have - // to re-deserialize anything cpex-core has already validated. - let parsed_routes: Vec = cpex_config.routes.clone(); - let parsed_plugins: Vec = cpex_config.plugins.clone(); - - // Existing plugin-instantiation path. - self.load_config(cpex_config)?; - - // Visitor walk. No-op when no visitors registered — the common - // case for hosts that don't use the orchestrator extension point. - let visitors = { - let v = self.visitors.read().unwrap_or_else(|p| p.into_inner()); - if v.is_empty() { - return Ok(()); - } - v.clone() - }; - - let mgr: Arc = Arc::clone(self); - let global_yaml = raw - .get("global") - .cloned() - .unwrap_or(serde_yaml::Value::Null); - let defaults_yaml = global_yaml - .get("defaults") - .and_then(serde_yaml::Value::as_mapping) - .cloned(); - // Bundles the visitor compiles come from BOTH the canonical - // top-level `groups:` and the deprecated `global.policies:`, merged - // with top-level winning on a name collision — mirroring - // `merge_groups_into_policies` on the typed side, so a top-level - // group's `authorization:` / `apl:` gets compiled too. - let policies_yaml = { - let from_policies = global_yaml - .get("policies") - .and_then(serde_yaml::Value::as_mapping) - .cloned(); - let from_groups = raw - .get("groups") - .and_then(serde_yaml::Value::as_mapping) - .cloned(); - match (from_policies, from_groups) { - (None, None) => None, - (Some(p), None) => Some(p), - (None, Some(g)) => Some(g), - (Some(mut p), Some(g)) => { - for (k, v) in g { - p.insert(k, v); - } - Some(p) - }, - } - }; - let routes_yaml: Vec = raw - .get("routes") - .and_then(serde_yaml::Value::as_sequence) - .cloned() - .unwrap_or_default(); - - for visitor in &visitors { - visitor.visit_plugins(&mgr, &parsed_plugins).map_err(|e| { - Box::new(PluginError::Config { - message: format!("visitor '{}' visit_plugins: {}", visitor.name(), e), - }) - })?; - - visitor.visit_global(&mgr, &global_yaml).map_err(|e| { - Box::new(PluginError::Config { - message: format!("visitor '{}' visit_global: {}", visitor.name(), e), - }) - })?; - - if let Some(defaults) = &defaults_yaml { - for (k, v) in defaults { - let Some(entity_type) = k.as_str() else { - continue; - }; - visitor.visit_default(&mgr, entity_type, v).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "visitor '{}' visit_default('{}'): {}", - visitor.name(), - entity_type, - e - ), - }) - })?; - } - } - - if let Some(policies) = &policies_yaml { - for (k, v) in policies { - let Some(tag) = k.as_str() else { continue }; - visitor.visit_policy_bundle(&mgr, tag, v).map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "visitor '{}' visit_policy_bundle('{}'): {}", - visitor.name(), - tag, - e - ), - }) - })?; - } - } - - for (i, parsed) in parsed_routes.iter().enumerate() { - let route_yaml = routes_yaml - .get(i) - .cloned() - .unwrap_or(serde_yaml::Value::Null); - visitor - .visit_route(&mgr, &route_yaml, parsed) - .map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "visitor '{}' visit_route[{}]: {}", - visitor.name(), - i, - e - ), - }) - })?; - } - } - - Ok(()) - } - - /// Create a PluginManager from a parsed config (convenience). - /// - /// Uses the passed factory registry for initial instantiation. - /// Note: for route-level config overrides to create new instances - /// at runtime, use `register_factory()` + `load_config()` instead - /// so the manager owns the factories. - pub fn from_config( - cpex_config: CpexConfig, - factories: &PluginFactoryRegistry, - ) -> Result> { - warn_on_inactive_settings(&cpex_config); - - let manager = Self::new(ManagerConfig { - executor: ExecutorConfig::default(), - route_cache_max_entries: cpex_config.plugin_settings.route_cache_max_entries, - }); - - // Instantiate into a fresh registry, then publish atomically. - let mut new_registry = PluginRegistry::new(); - instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, factories)?; - - manager - .runtime - .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); - - Ok(manager) - } - - /// Register a plugin handler for its primary hook name. - /// - /// This is the preferred registration method. The framework creates - /// the type-erased adapter internally — no `AnyHookHandler` needed. - /// - /// # Type Parameters - /// - /// - `H` — the hook type (implements `HookTypeDef`). - /// - `P` — the plugin type (implements `Plugin + HookHandler`). - /// - /// # Arguments - /// - /// - `plugin` — the plugin implementation. - /// - `config` — authoritative config from the config loader. - /// - /// # Examples - /// - /// ```rust,ignore - /// manager.register_handler::(plugin, config)?; - /// ``` - pub fn register_handler( - &self, - plugin: Arc

, - config: PluginConfig, - ) -> Result<(), Box> - where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, - { - let handler: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - self.try_mutate_runtime(|snap| { - snap.registry - .register::(plugin, config, handler) - .map_err(|msg| Box::new(PluginError::Config { message: msg })) - })?; - self.clear_routing_cache(); - Ok(()) - } - - /// Register a plugin handler for multiple hook names. - /// - /// This is the CMF pattern — one handler covers multiple hook - /// names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). - /// - /// # Examples - /// - /// ```rust,ignore - /// manager.register_handler_for_names::( - /// plugin, config, - /// &["cmf.tool_pre_invoke", "cmf.llm_input", "cmf.llm_output"], - /// )?; - /// ``` - pub fn register_handler_for_names( - &self, - plugin: Arc

, - config: PluginConfig, - names: &[&str], - ) -> Result<(), Box> - where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, - { - let handler: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - self.try_mutate_runtime(|snap| { - snap.registry - .register_for_names::(plugin, config, handler, names) - .map_err(|msg| Box::new(PluginError::Config { message: msg })) - })?; - self.clear_routing_cache(); - Ok(()) - } - - /// Register with an explicit AnyHookHandler (advanced use). - /// - /// For cases where the automatic adapter doesn't fit — e.g., - /// Python/WASM bridge hosts that implement AnyHookHandler directly. - /// Most callers should use `register_handler` instead. - pub fn register_raw( - &self, - plugin: Arc, - config: PluginConfig, - handler: Arc, - ) -> Result<(), Box> { - self.try_mutate_runtime(|snap| { - snap.registry - .register::(plugin, config, handler) - .map_err(|msg| Box::new(PluginError::Config { message: msg })) - })?; - self.clear_routing_cache(); - Ok(()) - } - - /// Initialize all registered plugins. - /// - /// Calls `plugin.initialize()` on each registered plugin. Must be - /// called before invoking any hooks. Idempotent — calling twice - /// has no effect. - pub async fn initialize(&self) -> Result<(), Box> { - if self.initialized.load(Ordering::Acquire) { - return Ok(()); - } - - // Snapshot once at start — subsequent registrations don't affect - // this initialize() call. They'd need their own initialize. - let snapshot = self.load_runtime(); - - info!( - "Initializing PluginManager with {} plugins", - snapshot.registry.plugin_count() - ); - - let mut initialized_plugins: Vec = Vec::new(); - - for name in snapshot.registry.plugin_names() { - if let Some(plugin_ref) = snapshot.registry.get(&name) { - let plugin = plugin_ref.plugin().clone(); - let plugin_name = name; - - if let Err(e) = plugin.initialize().await { - error!("Failed to initialize plugin '{}': {}", plugin_name, e); - - for init_name in initialized_plugins.iter().rev() { - if let Some(pr) = snapshot.registry.get(init_name) { - if let Err(shutdown_err) = pr.plugin().shutdown().await { - error!( - "Error shutting down plugin '{}' during rollback: {}", - init_name, shutdown_err - ); - } - } - } - - return Err(Box::new(PluginError::Execution { - plugin_name, - message: format!("initialization failed: {}", e), - source: Some(Box::new(e)), - code: None, - details: std::collections::HashMap::new(), - proto_error_code: None, - })); - } - - initialized_plugins.push(plugin_name); - } - } - - self.initialized.store(true, Ordering::Release); - info!("PluginManager initialized successfully"); - Ok(()) - } - - /// Shutdown all registered plugins. - /// - /// Calls `plugin.shutdown()` on each registered plugin in reverse - /// registration order. Errors are logged but do not halt the - /// shutdown process — all plugins get a chance to clean up. - /// Shut the manager down. **Terminal:** after `shutdown()` returns, - /// no further `register_*` / `invoke_*` should be called. New - /// fire-and-forget tasks spawned after `close()` will not be tracked - /// (the `TaskTracker` is single-shot by design). - pub async fn shutdown(&self) { - if !self.initialized.load(Ordering::Acquire) { - return; - } - - info!("Shutting down PluginManager"); - - // Drain in-flight fire-and-forget tasks BEFORE tearing down - // plugins — otherwise audit/telemetry tasks that depend on the - // plugin being alive (or the runtime being up) get cancelled - // mid-flight. `close()` prevents new tasks from being tracked - // (existing in-flight ones still complete); `wait()` returns - // when the in-flight count drops to zero. - self.task_tracker.close(); - self.task_tracker.wait().await; - - let snapshot = self.load_runtime(); - for name in snapshot.registry.plugin_names() { - if let Some(plugin_ref) = snapshot.registry.get(&name) { - let plugin = plugin_ref.plugin().clone(); - - if let Err(e) = plugin.shutdown().await { - error!("Error shutting down plugin '{}': {}", name, e); - // Continue — don't let one plugin's failure block others - } - } - } - - self.initialized.store(false, Ordering::Release); - info!("PluginManager shutdown complete"); - } - - /// Invoke a hook by name with a type-erased payload. - /// - /// This is the dynamic dispatch path used by Python/Go/WASM - /// callers via FFI or PyO3 bindings. The hook name is resolved - /// from the registry and dispatched through the 5-phase executor. - /// - /// # Arguments - /// - /// * `hook_name` — the hook name string (e.g., `"cmf.tool_pre_invoke"`). - /// * `payload` — the payload as `Box`. - /// * `extensions` — the full extensions (filtered per plugin by the executor). - /// * `context_table` — optional context table from a previous hook - /// invocation. Pass `None` on the first hook call; thread the - /// returned table into subsequent calls to preserve per-plugin state. - /// - /// # Returns - /// - /// A tuple of `(PipelineResult, BackgroundTasks)`. The result - /// contains the final payload, extensions, violation, and context - /// table. Background tasks can be awaited or dropped. - pub async fn invoke_by_name( - &self, - hook_name: &str, - payload: Box, - extensions: Extensions, - context_table: Option, - ) -> (PipelineResult, BackgroundTasks) { - // Single atomic load — own the snapshot for the rest of the call so - // a concurrent register/load_config swapping in a new snapshot doesn't - // change our view mid-pipeline. - let snapshot = self.load_runtime(); - let hook_type = HookType::new(hook_name); - let all_entries = snapshot.registry.entries_for_hook(&hook_type); - - // Same caveat as `invoke_named`: route annotations can produce a - // dispatch entry without any plugin being registered on the - // hook directly, so we can only short-circuit when both the - // registry and the annotation map are empty. - if all_entries.is_empty() && snapshot.route_annotations.is_empty() { - return ( - PipelineResult::allowed_with( - payload, - extensions, - context_table.unwrap_or_default(), - ), - BackgroundTasks::empty(), - ); - } - - let entries = self - .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name) - .await; - - if entries.is_empty() { - return ( - PipelineResult::allowed_with( - payload, - extensions, - context_table.unwrap_or_default(), - ), - BackgroundTasks::empty(), - ); - } - - snapshot - .executor - .execute( - &entries, - payload, - extensions, - context_table, - &self.task_tracker, - ) - .await - } - - /// Invoke a typed hook. - /// - /// This is the compile-time dispatch path used by Rust callers. - /// The hook type `H` determines the payload and result types. - /// Dispatch goes through the same registry and 5-phase executor - /// as `invoke_by_name()`. - /// - /// When routing is enabled, the entity is identified from - /// `extensions.meta` (entity_type + entity_name). Only plugins - /// matching the resolved route fire. When routing is disabled - /// or meta is absent, all registered plugins fire. - /// - /// # Type Parameters - /// - /// - `H` — the hook type (implements `HookTypeDef`). - /// - /// # Arguments - /// - /// * `payload` — the typed payload. - /// * `extensions` — the full extensions (includes meta for routing). - /// * `context_table` — optional context table from a previous hook. - /// - /// # Returns - /// - /// A tuple of `(PipelineResult, BackgroundTasks)`. - pub async fn invoke( - &self, - payload: H::Payload, - extensions: Extensions, - context_table: Option, - ) -> (PipelineResult, BackgroundTasks) { - let snapshot = self.load_runtime(); - let hook_type = HookType::new(H::NAME); - let all_entries = snapshot.registry.entries_for_hook(&hook_type); - - // See `invoke_named` for why we don't short-circuit on - // `all_entries.is_empty()` alone — route annotations can fire - // without a directly-registered plugin. - if all_entries.is_empty() && snapshot.route_annotations.is_empty() { - let boxed: Box = Box::new(payload); - return ( - PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), - BackgroundTasks::empty(), - ); - } - - let entries = self - .filter_entries_by_route(&snapshot, all_entries, &extensions, H::NAME) - .await; - - if entries.is_empty() { - let boxed: Box = Box::new(payload); - return ( - PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), - BackgroundTasks::empty(), - ); - } - - let boxed: Box = Box::new(payload); - snapshot - .executor - .execute( - &entries, - boxed, - extensions, - context_table, - &self.task_tracker, - ) - .await - } - - /// Invoke a typed hook by explicit name. - /// - /// Combines compile-time payload type checking (from `H`) with - /// runtime hook name routing (from `hook_name`). Use this when - /// a single hook type (e.g., `CmfHook`) covers multiple hook - /// names (e.g., `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`). - /// - /// # Type Parameters - /// - /// - `H` — the hook type (provides payload type checking). - /// - /// # Arguments - /// - /// * `hook_name` — the hook name for dispatch routing. - /// * `payload` — the typed payload (compile-time checked against `H::Payload`). - /// * `extensions` — the full extensions. - /// * `context_table` — optional context table from a previous hook. - /// - /// # Examples - /// - /// ```rust,ignore - /// // Compile-time: payload must be MessagePayload (from CmfHook) - /// // Runtime: dispatches to plugins registered under "cmf.tool_pre_invoke" - /// let (result, bg) = mgr.invoke_named::( - /// "cmf.tool_pre_invoke", payload, ext, None, - /// ).await; - /// ``` - pub async fn invoke_named( - &self, - hook_name: &str, - payload: H::Payload, - extensions: Extensions, - context_table: Option, - ) -> (PipelineResult, BackgroundTasks) { - let snapshot = self.load_runtime(); - let hook_type = HookType::new(hook_name); - let all_entries = snapshot.registry.entries_for_hook(&hook_type); - - // No registered entries AND no route annotations → nothing to - // do. Allow-and-pass-through. We can't short-circuit on - // `all_entries.is_empty()` alone, because route annotations - // (external-orchestrator handlers from APL / future Rego / - // Cedar-direct) can produce a single-entry dispatch even when - // no plugin was registered on the hook directly. - if all_entries.is_empty() && snapshot.route_annotations.is_empty() { - let boxed: Box = Box::new(payload); - return ( - PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), - BackgroundTasks::empty(), - ); - } - - let entries = self - .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name) - .await; - - if entries.is_empty() { - let boxed: Box = Box::new(payload); - return ( - PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), - BackgroundTasks::empty(), - ); - } - - let boxed: Box = Box::new(payload); - snapshot - .executor - .execute( - &entries, - boxed, - extensions, - context_table, - &self.task_tracker, - ) - .await - } - - /// Find every (hook_name, HookEntry) pair belonging to the named - /// plugin. Returns an empty `Vec` if the plugin isn't registered. - /// - /// Used by external orchestrators (notably apl-cpex) that decide - /// the per-route plugin lineup themselves and need handler refs + - /// trusted_config to build pre-resolved dispatch plans. Cheaper than - /// going through `invoke_named` per request because the caller can - /// cache the resulting entries — pair the result with - /// [`config_generation`](Self::config_generation) to invalidate the - /// cache on snapshot swaps. - /// - /// Bypasses route/entity filtering — caller has already decided this - /// plugin should run. APL's `routes:` is itself the authoritative - /// lineup; cpex-core's condition-based routing is a parallel model - /// for non-APL hosts. - pub fn find_plugin_entries( - &self, - plugin_name: &str, - ) -> Vec<(String, crate::registry::HookEntry)> { - let snapshot = self.load_runtime(); - snapshot.registry.entries_for_plugin(plugin_name) - } - - /// Dispatch a caller-supplied slice of HookEntries through the - /// executor's full 5-phase pipeline (sequential, transform, audit, - /// concurrent, fire-and-forget). All on_error / timeout / mode / - /// write-token machinery applies. - /// - /// Bypasses hook-name lookup and route/entity filtering — caller has - /// already resolved the lineup (typically via - /// [`find_plugin_entries`](Self::find_plugin_entries) + a per-route - /// dispatch plan). The `H: HookTypeDef` parameter enforces payload - /// type at compile time; mismatched payloads fail to compile, same - /// as [`invoke_named`](Self::invoke_named). - /// - /// Returns `(PipelineResult, BackgroundTasks)` identical in shape to - /// `invoke_named` so callers can swap between the two paths without - /// rewriting downstream result handling. - pub async fn invoke_entries( - &self, - entries: &[crate::registry::HookEntry], - payload: H::Payload, - extensions: Extensions, - context_table: Option, - ) -> (PipelineResult, BackgroundTasks) { - if entries.is_empty() { - let boxed: Box = Box::new(payload); - return ( - PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), - BackgroundTasks::empty(), - ); - } - let snapshot = self.load_runtime(); - let boxed: Box = Box::new(payload); - snapshot - .executor - .execute( - entries, - boxed, - extensions, - context_table, - &self.task_tracker, - ) - .await - } - - /// Override the resolved plugin list for one `(entity_type, entity_name)` - /// pair on the listed hooks with a single synthetic handler. The handler - /// takes responsibility for any further plugin dispatch within itself - /// (typically by calling [`invoke_entries`](Self::invoke_entries) against - /// the same registry's other entries — i.e. APL's `plugin(name)` → - /// `CmfPluginInvoker` → `invoke_entries` flow). - /// - /// This is the integration point external orchestrators (APL, future - /// Rego/Cedar-direct/Custom) use to drive plugins via their own - /// semantics instead of cpex-core's imperative `routes.*.plugins:` - /// chain. Bumps the config generation so cached dispatch plans in - /// downstream caches invalidate. - /// - /// `config` provides the trusted_config for the synthetic plugin — - /// the executor reads `mode`, `on_error`, `capabilities`, etc. from - /// it the same way it does for any other registered plugin. Capabilities - /// should be a *superset* of what the orchestrator needs to read from - /// `Extensions` (cpex-core's per-plugin filter still applies to the - /// synthetic handler). - /// - /// The underlying `plugins:` chain for this route is *not* removed — - /// those plugins stay discoverable via [`find_plugin_entries`](Self::find_plugin_entries) - /// so the orchestrator can dispatch into them by name. - pub fn annotate_route( - &self, - entity_type: impl Into, - entity_name: impl Into, - scope: Option, - hook_name: impl Into, - handler: Arc, - config: crate::plugin::PluginConfig, - ) where - H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static, - { - let key = AnnotationKey { - entity_type: entity_type.into(), - entity_name: entity_name.into(), - scope, - hook_name: hook_name.into(), - }; - let plugin_ref = Arc::new(crate::registry::PluginRef::new( - handler.clone() as Arc, - config, - )); - let entry = crate::registry::HookEntry { - plugin_ref, - handler: handler as Arc, - }; - self.mutate_runtime(|snap| { - snap.route_annotations.insert(key, entry); - }); - } - - /// Remove a route annotation for a specific hook. No-op when no - /// annotation exists for the key. Bumps the generation so downstream - /// caches invalidate. - pub fn remove_route_annotation( - &self, - entity_type: &str, - entity_name: &str, - scope: Option<&str>, - hook_name: &str, - ) { - let key = AnnotationKey { - entity_type: entity_type.to_string(), - entity_name: entity_name.to_string(), - scope: scope.map(str::to_string), - hook_name: hook_name.to_string(), - }; - self.mutate_runtime(|snap| { - snap.route_annotations.remove(&key); - }); - } - - /// Filter hook entries based on route resolution, with caching. - /// - /// When routing is enabled and extensions.meta provides entity - /// identification, resolves the route and returns only the entries - /// for plugins that match. Results are cached by - /// `(entity_type, entity_name, hook_name, scope)` — subsequent - /// calls for the same key return an `Arc` to the cached entries - /// (refcount bump, no data copy). - /// - /// When routing is disabled or meta is absent, returns all entries. - async fn filter_entries_by_route( - &self, - snapshot: &RuntimeSnapshot, - entries: &[crate::registry::HookEntry], - extensions: &Extensions, - hook_name: &str, - ) -> Arc> { - // Route annotation short-circuit: if the request's - // (entity_type, entity_name) has an annotation that handles this - // hook, return a one-entry list containing the annotated handler. - // External orchestrators (APL via apl-cpex; future Rego/Cedar) - // register annotations to drive plugin dispatch under their own - // semantics instead of cpex-core's imperative chain. Underlying - // `plugins:` entries stay in the registry for the orchestrator - // to dispatch into by-name via `invoke_entries`. - if !snapshot.route_annotations.is_empty() { - if let Some(meta) = &extensions.meta { - if let (Some(et), Some(en)) = (&meta.entity_type, &meta.entity_name) { - // Scoped lookup first (specific wins); unscoped lookup - // falls back as a "global default" — matches the - // specificity tiebreaker `find_matching_route` uses. - // Lookup is keyed on the hook name as well, so a route - // can install distinct handlers per phase. - let scoped = meta.scope.as_ref().and_then(|s| { - snapshot.route_annotations.get(&AnnotationKey { - entity_type: et.clone(), - entity_name: en.clone(), - scope: Some(s.clone()), - hook_name: hook_name.to_string(), - }) - }); - let candidate = scoped.or_else(|| { - snapshot.route_annotations.get(&AnnotationKey { - entity_type: et.clone(), - entity_name: en.clone(), - scope: None, - hook_name: hook_name.to_string(), - }) - }); - if let Some(entry) = candidate { - return Arc::new(vec![entry.clone()]); - } - } - } - } - - // Routing disabled (or no config): fall back to per-plugin - // condition filtering. Empty conditions Vec means "fire always", - // so this is backward-compatible with configs that don't use - // conditions. Mirrors the Python implementation. - let cpex_config = match &snapshot.cpex_config { - Some(c) if c.routing_enabled() => c, - _ => { - let filtered: Vec<_> = entries - .iter() - .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions)) - .cloned() - .collect(); - return Arc::new(filtered); - }, - }; - - let meta = match &extensions.meta { - Some(m) => m, - None => return Arc::new(entries.to_vec()), - }; - - let (entity_type, entity_name) = match (&meta.entity_type, &meta.entity_name) { - (Some(t), Some(n)) => (t.as_str(), n.as_str()), - _ => return Arc::new(entries.to_vec()), - }; - - let request_scope = meta.scope.as_deref(); - - // Fast path: zero-allocation cache lookup with raw_entry - let hash = { - use std::hash::BuildHasher; - let mut hasher = self.cache_hasher.build_hasher(); - entity_type.hash(&mut hasher); - entity_name.hash(&mut hasher); - hook_name.hash(&mut hasher); - request_scope.hash(&mut hasher); - hasher.finish() - }; - { - // Recover from poisoning: a panic in another thread while holding - // this lock leaves the cache flagged poisoned. The cache's contents - // are still valid (HashMap operations are panic-safe and stale - // entries are healed by `clear_routing_cache()`), so we don't want - // a one-time panic to permanently disable dispatch. Same idiom - // applies to all four lock sites in this file. - let cache = self - .route_cache - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| { - key.entity_type == entity_type - && key.entity_name == entity_name - && key.hook_name == hook_name - && key.scope.as_deref() == request_scope - }) { - return Arc::clone(cached); - } - } - - // Slow path: resolve, filter, and cache (allocations only here). - // - // Hook-specific resolution for identity.resolve: the route's - // `identity:` block is the authoritative dispatch list (NOT - // the `plugins:` block, which in APL-driven routes means - // "per-route overrides" rather than "binding"). For every - // other hook, the generic plugins-block resolution applies. - let resolved = if hook_name == crate::identity::HOOK_IDENTITY_RESOLVE { - config::resolve_identity_plugins_for_route( - cpex_config, - entity_type, - entity_name, - request_scope, - ) - } else { - config::resolve_plugins_for_entity( - cpex_config, - entity_type, - entity_name, - request_scope, - &meta.tags, - ) - }; - - // Filter entries to resolved plugins, preserving resolution order. - // If a plugin has config overrides and we have a factory for its kind, - // create a new instance with the merged config. - let mut filtered = Vec::new(); - for resolved_plugin in &resolved { - if let Some(entry) = entries - .iter() - .find(|e| e.plugin_ref.name() == resolved_plugin.name) - { - if let Some(overrides) = &resolved_plugin.config_overrides { - // Try to create an override instance - if let Some(override_entry) = - self.create_override_instance(entry, overrides).await - { - filtered.push(override_entry); - continue; - } - } - filtered.push(entry.clone()); - } - } - - let cached = Arc::new(filtered); - - // Store in cache — owned key allocated only on cache miss. - // Reject-on-full: when the cache is at capacity we still return - // the freshly resolved Vec but skip memoization, bounding memory - // growth from attacker-controlled entity names. - let cache_key = RouteCacheKey { - entity_type: entity_type.to_string(), - entity_name: entity_name.to_string(), - hook_name: hook_name.to_string(), - scope: meta.scope.clone(), - }; - // Decide under the lock; log outside it so I/O doesn't block readers. - // One warn per fill cycle — prevents log spam under DoS. - let should_warn = { - let mut cache = self - .route_cache - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if cache.len() >= snapshot.route_cache_max_entries { - !self.route_cache_full_warned.swap(true, Ordering::AcqRel) - } else { - cache.insert(cache_key, Arc::clone(&cached)); - false - } - }; - if should_warn { - warn!( - max_entries = snapshot.route_cache_max_entries, - "Routing cache at capacity — further routes will not be cached. \ - Increase plugin_settings.route_cache_max_entries or \ - investigate entity name growth.", - ); - } - - cached - } - - /// Build per-hook `HookEntry`s for a plugin with optional route- - /// level overrides. Used by external orchestrators (notably - /// apl-cpex's dispatch plan) that need to splice per-route plugin - /// variants — different `config`, narrower `capabilities`, different - /// `on_error` — into the dispatch lineup while keeping cpex-core - /// the source of truth for instantiation and isolation. - /// - /// Behavior: - /// - **All three overrides `None`:** returns the base entries - /// unchanged. Caller can use them as-is. - /// - **Only `capabilities_override` / `on_error_override` set - /// (`config_override` is `None`):** builds new `PluginRef`s - /// sharing the *base plugin `Arc`* with a merged `TrustedConfig` - /// (override caps / on_error replace base values) and an - /// independent circuit breaker. Cheap — no factory call. - /// - **`config_override` set:** invokes the registered factory for - /// the plugin's `kind` with a merged `PluginConfig` (override - /// `config` *replaces* base `config` wholesale per unified-config - /// spec — not deep merge), calls `initialize()` on the new - /// instance, and wraps every returned handler in a new - /// `PluginRef` with a fresh circuit breaker. - /// - /// Returns an empty `Vec` when: - /// - the plugin name isn't registered in the manager, - /// - the factory for the plugin's `kind` is missing, - /// - the factory's `create` errors, - /// - or `initialize()` fails on the new instance. - /// - /// Each of those is a configuration / wiring fault the caller - /// should treat as `NotFound` at dispatch time. The method logs - /// the underlying error before returning empty so debugging - /// surfaces in operator logs rather than as a silent miss. - pub async fn build_override_entries( - &self, - plugin_name: &str, - config_override: Option<&serde_yaml::Value>, - capabilities_override: Option<&std::collections::HashSet>, - on_error_override: Option, - ) -> Vec<(String, crate::registry::HookEntry)> { - let base_entries = self.find_plugin_entries(plugin_name); - if base_entries.is_empty() { - return Vec::new(); - } - - // No overrides at all — caller can use base entries unchanged. - if config_override.is_none() - && capabilities_override.is_none() - && on_error_override.is_none() - { - return base_entries; - } - - // Pull the base trusted_config off any of the base entries — - // all of them share the same `Arc` for a given - // plugin name, so picking the first is fine. - let base_ref = Arc::clone(&base_entries[0].1.plugin_ref); - let mut merged_config = base_ref.trusted_config().clone(); - - // Capabilities: override replaces base when present. - if let Some(caps) = capabilities_override { - merged_config.capabilities = caps.clone(); - } - - // on_error: override replaces base when present. - if let Some(oe) = on_error_override { - merged_config.on_error = oe; - } - - // Caps/on_error-only path — shared base plugin Arc, new - // PluginRef with merged config + fresh circuit breaker. - // No factory call, no async work. - if config_override.is_none() { - let new_ref = Arc::new(crate::registry::PluginRef::new( - Arc::clone(base_ref.plugin()), - merged_config, - )); - return base_entries - .into_iter() - .map(|(hook_name, base_entry)| { - ( - hook_name, - crate::registry::HookEntry { - plugin_ref: Arc::clone(&new_ref), - handler: base_entry.handler, - }, - ) - }) - .collect(); - } - - // Config override present — factory path. Convert YAML - // override value into the JSON shape `PluginConfig.config` - // carries (YAML is a superset of JSON so serde re-serialization - // is safe). Per spec, override `config` replaces the base - // `config` wholesale. - let cfg_yaml = config_override.expect("checked above"); - let cfg_json = match serde_json::to_value(cfg_yaml) { - Ok(v) => v, - Err(e) => { - error!( - plugin = %plugin_name, - error = %e, - "build_override_entries: YAML→JSON config conversion failed", - ); - return Vec::new(); - }, - }; - merged_config.config = Some(cfg_json); - - let kind = merged_config.kind.clone(); - let instance = { - let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); - let factory = match factories.get(&kind) { - Some(f) => f, - None => { - error!( - plugin = %plugin_name, - kind = %kind, - "build_override_entries: no factory registered for kind", - ); - return Vec::new(); - }, - }; - match factory.create(&merged_config) { - Ok(i) => i, - Err(e) => { - error!( - plugin = %plugin_name, - error = %e, - "build_override_entries: factory.create failed", - ); - return Vec::new(); - }, - } - }; - - if let Err(e) = instance.plugin.initialize().await { - error!( - plugin = %plugin_name, - error = %e, - "build_override_entries: initialize() failed on new instance", - ); - return Vec::new(); - } - - // One PluginRef shared across the new instance's handlers — - // all hooks served by one instance share a circuit breaker - // (matches registration semantics). - let new_ref = Arc::new(crate::registry::PluginRef::new( - Arc::clone(&instance.plugin), - merged_config, - )); - instance - .handlers - .into_iter() - .map(|(hook_name, handler)| { - ( - hook_name.to_string(), - crate::registry::HookEntry { - plugin_ref: Arc::clone(&new_ref), - handler, - }, - ) - }) - .collect() - } - - /// Create an override plugin instance with merged config. - /// - /// When a route overrides a plugin's config, we create a new - /// instance via the factory with the merged config and call - /// `initialize()` on it so plugins that open DB connections / file - /// handles / network clients run their setup. - /// - /// The override gets its OWN circuit breaker (`disabled` flag) and - /// its own UUID, independent of the base. Config is part of the - /// failure surface — an override with a bad connection string / - /// wrong credentials / wrong limit value can fail for reasons that - /// have nothing to do with the base's reliability. Coupling them - /// would let a config-specific failure on one route silently - /// disable the plugin on every other route, which is the opposite - /// of the per-route blast-radius guarantee operators reach for - /// overrides to get. The fresh UUID also keys the override's - /// `local_state` in the context table, isolating per-instance - /// state from the base for the same reason. - /// - /// Returns `None` (and the caller falls back to the base entry) if: - /// - no factory is available for the plugin's kind, - /// - the factory fails to create the instance, - /// - the new instance has no handler for the target hook, - /// - or `initialize()` fails on the new instance. - async fn create_override_instance( - &self, - base_entry: &crate::registry::HookEntry, - overrides: &serde_json::Value, - ) -> Option { - let base_config = base_entry.plugin_ref.trusted_config(); - let kind = &base_config.kind; - - // Merge: start with base config, overlay with overrides - let mut merged_config = base_config.clone(); - if let Some(override_config) = overrides.get("config") { - // Merge the plugin-specific config section - if let Some(base_plugin_config) = &merged_config.config { - let mut merged = base_plugin_config.clone(); - if let (Some(base_obj), Some(override_obj)) = - (merged.as_object_mut(), override_config.as_object()) - { - for (key, value) in override_obj { - base_obj.insert(key.clone(), value.clone()); - } - } - merged_config.config = Some(merged); - } else { - merged_config.config = Some(override_config.clone()); - } - } - - // Create new instance with merged config — hold the factories - // read lock just long enough to construct the instance, then drop - // it before any `.await` so we never hold a sync lock across awaits. - let target_hook = base_entry.handler.hook_type_name(); - let instance = { - let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); - let factory = match factories.get(kind) { - Some(f) => f, - None => return None, - }; - match factory.create(&merged_config) { - Ok(i) => i, - Err(e) => { - error!( - "Failed to create override instance for '{}': {}", - base_config.name, e - ); - return None; // fall back to base instance - }, - } - }; - - // Find the handler matching the current hook before consuming - // the instance so we don't pay for initialization on a doomed instance. - let handler = instance - .handlers - .into_iter() - .find(|(name, _)| *name == target_hook) - .map(|(_, h)| h); - let handler = match handler { - Some(h) => h, - None => { - warn!( - "Override instance for '{}' has no handler for hook '{}'", - base_config.name, target_hook - ); - return None; - }, - }; - - // Initialize the new instance — without this, plugins that need to - // set up DB connections / file handles / network clients run with - // default state. - if let Err(e) = instance.plugin.initialize().await { - error!( - "Failed to initialize override instance for '{}': {} — falling back to base", - base_config.name, e - ); - return None; - } - - // Independent circuit breaker + fresh UUID per (kind, name, config) - // — see the doc comment above for why we don't share with the base. - // Arc-wrapped for cheap cloning under group_by_mode. - let plugin_ref = Arc::new(crate::registry::PluginRef::new( - instance.plugin, - merged_config, - )); - Some(crate::registry::HookEntry { - plugin_ref, - handler, - }) - } - - /// Clear the routing cache. Call when config is reloaded or - /// plugins are registered/unregistered. Also resets the - /// "cache full" warn-once latch so the next fill cycle can warn again. - pub fn clear_routing_cache(&self) { - let mut cache = self - .route_cache - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - cache.clear(); - self.route_cache_full_warned.store(false, Ordering::Release); - } - - /// Number of entries in the routing cache. - pub fn routing_cache_size(&self) -> usize { - self.route_cache - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .len() - } - - /// Whether anything would run for the given hook name — either a - /// registered plugin handler OR a route annotation targeting that hook. - /// - /// Route annotations (installed by APL from a route's `policy:` / - /// `args:` / `result:` blocks) must be counted here: a route whose only - /// handler for a phase is an annotation (e.g. a response-side - /// `result: { ssn: redact(...) }` on `cmf.tool_post_invoke`, with no - /// globally-registered post-invoke plugin) would otherwise report - /// "no hooks" and be skipped by out-of-process hosts that use this as a - /// fast-skip gate — silently dropping the route's policy for that phase. - pub fn has_hooks_for(&self, hook_name: &str) -> bool { - let snapshot = self.load_runtime(); - snapshot.registry.has_hooks_for(&HookType::new(hook_name)) - || snapshot - .route_annotations - .keys() - .any(|k| k.hook_name.as_str() == hook_name) - } - - /// Look up a plugin by name. Returns an `Arc` clone — works - /// with the snapshot-based dispatch model where the registry sits - /// behind a transient `Arc` guard. `Arc` - /// derefs to `PluginRef`, so callers can chain methods directly: - /// `mgr.get_plugin("name").unwrap().is_disabled()` still compiles. - pub fn get_plugin(&self, name: &str) -> Option> { - self.load_runtime().registry.get(name) - } - - /// Total number of registered plugins. - pub fn plugin_count(&self) -> usize { - self.load_runtime().registry.plugin_count() - } - - /// All registered plugin names (owned, not borrowed from the registry). - pub fn plugin_names(&self) -> Vec { - self.load_runtime().registry.plugin_names() - } - - /// Whether the manager has been initialized. - pub fn is_initialized(&self) -> bool { - self.initialized.load(Ordering::Acquire) - } - - /// Unregister a plugin by name. - pub fn unregister(&self, name: &str) -> Option> { - let removed = self.mutate_runtime(|snap| snap.registry.unregister(name)); - if removed.is_some() { - self.clear_routing_cache(); - } - removed - } -} - -impl Default for PluginManager { - fn default() -> Self { - Self::new(ManagerConfig::default()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::context::PluginContext; - use crate::error::PluginViolation; - use crate::hooks::payload::Extensions; - use crate::hooks::{HookHandler, PluginResult}; - use crate::plugin::{OnError, PluginMode}; - use async_trait::async_trait; - - // -- Test payload -- - - #[derive(Debug, Clone)] - struct TestPayload { - value: String, - } - crate::impl_plugin_payload!(TestPayload); - - // -- Test hook type -- - - struct TestHook; - impl HookTypeDef for TestHook { - type Payload = TestPayload; - type Result = PluginResult; - const NAME: &'static str = "test_hook"; - } - - // -- Test plugins: implement Plugin + HookHandler -- - // No AnyHookHandler boilerplate — the framework handles it. - - /// Plugin that allows everything. - struct AllowPlugin { - cfg: PluginConfig, - } - - #[async_trait] - impl Plugin for AllowPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - Ok(()) - } - } - - impl HookHandler for AllowPlugin { - async fn handle( - &self, - _payload: &TestPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } - } - - /// Plugin that denies everything. - struct DenyPlugin { - cfg: PluginConfig, - } - - #[async_trait] - impl Plugin for DenyPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - Ok(()) - } - } - - impl HookHandler for DenyPlugin { - async fn handle( - &self, - _payload: &TestPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(PluginViolation::new("denied", "test denial")) - } - } - - /// Handler that always returns an error (for testing on_error behavior). - struct ErrorHandler; - - #[async_trait] - impl AnyHookHandler for ErrorHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - Err(Box::new(PluginError::Execution { - plugin_name: "error-plugin".into(), - message: "simulated failure".into(), - source: None, - code: None, - details: std::collections::HashMap::new(), - proto_error_code: None, - })) - } - - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - // -- Helpers -- - - fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig { - make_config_with_on_error(name, priority, mode, OnError::Fail) - } - - fn make_config_with_on_error( - name: &str, - priority: i32, - mode: PluginMode, - on_error: OnError, - ) -> PluginConfig { - PluginConfig { - name: name.to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec!["test_hook".to_string()], - mode, - priority, - on_error, - capabilities: Default::default(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } - } - - fn make_config_with_conditions( - name: &str, - conditions: Vec, - ) -> PluginConfig { - let mut cfg = make_config(name, 10, PluginMode::Sequential); - cfg.conditions = conditions; - cfg - } - - // -- Tests -- - - #[tokio::test] - async fn test_manager_lifecycle() { - let mgr = PluginManager::default(); - assert!(!mgr.is_initialized()); - assert_eq!(mgr.plugin_count(), 0); - - mgr.initialize().await.unwrap(); - assert!(mgr.is_initialized()); - - // Idempotent - mgr.initialize().await.unwrap(); - - mgr.shutdown().await; - assert!(!mgr.is_initialized()); - } - - #[tokio::test] - async fn test_invoke_by_name_no_plugins() { - let mgr = PluginManager::default(); - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert!(result.modified_payload.is_some()); - } - - #[tokio::test] - async fn test_invoke_by_name_allow() { - let mgr = PluginManager::default(); - let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - } - - #[tokio::test] - async fn test_invoke_by_name_deny() { - let mgr = PluginManager::default(); - let config = make_config("deny-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(!result.continue_processing); - assert_eq!(result.violation.as_ref().unwrap().code, "denied"); - } - - #[tokio::test] - async fn test_invoke_typed() { - let mgr = PluginManager::default(); - let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload = TestPayload { - value: "typed".into(), - }; - - let (result, _) = mgr - .invoke::(payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - } - - #[tokio::test] - async fn test_invoke_named() { - // invoke_named::(hook_name, ...) gives compile-time payload - // type checking while routing to a specific hook name. - let mgr = PluginManager::default(); - let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload = TestPayload { - value: "named".into(), - }; - - // TestHook::NAME is "test_hook" — invoke_named routes by the - // explicit hook_name parameter, not H::NAME - let (result, _) = mgr - .invoke_named::("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - } - - #[tokio::test] - async fn test_invoke_named_no_plugins_for_hook() { - // invoke_named with a hook name that has no registered plugins - let mgr = PluginManager::default(); - let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload = TestPayload { - value: "no-match".into(), - }; - - // Plugin is registered under "test_hook", but we invoke "other_hook" - let (result, _) = mgr - .invoke_named::("other_hook", payload, Extensions::default(), None) - .await; - - // No plugins fire — allowed by default - assert!(result.continue_processing); - } - - #[tokio::test] - async fn test_invoke_named_deny() { - let mgr = PluginManager::default(); - let config = make_config("deny-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload = TestPayload { - value: "denied".into(), - }; - - let (result, _) = mgr - .invoke_named::("test_hook", payload, Extensions::default(), None) - .await; - - assert!(!result.continue_processing); - assert_eq!(result.violation.as_ref().unwrap().code, "denied"); - } - - #[tokio::test] - async fn test_has_hooks_for() { - let mgr = PluginManager::default(); - assert!(!mgr.has_hooks_for("test_hook")); - - let config = make_config("p1", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - mgr.register_handler::(plugin, config).unwrap(); - - assert!(mgr.has_hooks_for("test_hook")); - assert!(!mgr.has_hooks_for("other_hook")); - } - - /// When `routing_enabled` is `false` (the legacy / default mode), - /// each plugin's `conditions:` must be evaluated per request — a - /// non-matching condition should keep the plugin from firing. - /// Mirrors the Python implementation's per-plugin filtering. - #[tokio::test] - async fn test_conditions_filter_plugins_when_routing_disabled() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc as StdArc; - - let counts: StdArc<[AtomicUsize; 2]> = - StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]); - - struct CountingHandler { - idx: usize, - counts: StdArc<[AtomicUsize; 2]>, - } - #[async_trait] - impl AnyHookHandler for CountingHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - self.counts[self.idx].fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - // Plugin A: condition requires tool == "wanted_tool" — fires for matching requests. - let mut tools = std::collections::HashSet::new(); - tools.insert("wanted_tool".to_string()); - let cfg_a = make_config_with_conditions( - "plugin_a", - vec![crate::plugin::PluginCondition { - tools: Some(tools), - ..Default::default() - }], - ); - let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() }); - let handler_a: Arc = Arc::new(CountingHandler { - idx: 0, - counts: StdArc::clone(&counts), - }); - mgr.register_raw::(plugin_a, cfg_a, handler_a) - .unwrap(); - - // Plugin B: empty conditions — fires unconditionally. - let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential); - let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() }); - let handler_b: Arc = Arc::new(CountingHandler { - idx: 1, - counts: StdArc::clone(&counts), - }); - mgr.register_raw::(plugin_b, cfg_b, handler_b) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // Request 1: tool=wanted_tool → both A and B should fire. - let ext_match = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("wanted_tool".into()), - ..Default::default() - })), - ..Default::default() - }; - let p: Box = Box::new(TestPayload { value: "1".into() }); - let _ = mgr.invoke_by_name("test_hook", p, ext_match, None).await; - assert_eq!( - counts[0].load(Ordering::SeqCst), - 1, - "plugin_a should fire on matching tool" - ); - assert_eq!( - counts[1].load(Ordering::SeqCst), - 1, - "plugin_b should fire (no conditions)" - ); - - // Request 2: tool=other_tool → only B fires (A's condition rejects). - let ext_no_match = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("other_tool".into()), - ..Default::default() - })), - ..Default::default() - }; - let p: Box = Box::new(TestPayload { value: "2".into() }); - let _ = mgr.invoke_by_name("test_hook", p, ext_no_match, None).await; - assert_eq!( - counts[0].load(Ordering::SeqCst), - 1, - "plugin_a should NOT fire on non-matching tool" - ); - assert_eq!( - counts[1].load(Ordering::SeqCst), - 2, - "plugin_b should fire on every request" - ); - } - - /// `user_patterns` glob matches against `extensions.security.subject.id`. - /// Specifically: pattern `admin-*` matches `admin-alice` but not `user-bob`. - #[tokio::test] - async fn test_conditions_user_patterns_glob_filters() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - static FIRED: AtomicUsize = AtomicUsize::new(0); - FIRED.store(0, Ordering::SeqCst); - - struct CountHandler; - #[async_trait] - impl AnyHookHandler for CountHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - FIRED.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - let cfg = make_config_with_conditions( - "admin_only", - vec![crate::plugin::PluginCondition { - user_patterns: Some(vec!["admin-*".to_string()]), - ..Default::default() - }], - ); - let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() }); - let handler: Arc = Arc::new(CountHandler); - mgr.register_raw::(plugin, cfg, handler).unwrap(); - mgr.initialize().await.unwrap(); - - let ext_with_user = |id: &str| Extensions { - security: Some(std::sync::Arc::new(crate::extensions::SecurityExtension { - subject: Some(crate::extensions::security::SubjectExtension { - id: Some(id.to_string()), - ..Default::default() - }), - ..Default::default() - })), - ..Default::default() - }; - - let p: Box = Box::new(TestPayload { value: "1".into() }); - let _ = mgr - .invoke_by_name("test_hook", p, ext_with_user("admin-alice"), None) - .await; - assert_eq!( - FIRED.load(Ordering::SeqCst), - 1, - "admin-alice should match admin-*" - ); - - let p: Box = Box::new(TestPayload { value: "2".into() }); - let _ = mgr - .invoke_by_name("test_hook", p, ext_with_user("user-bob"), None) - .await; - assert_eq!( - FIRED.load(Ordering::SeqCst), - 1, - "user-bob should NOT match admin-*" - ); - } - - #[tokio::test] - async fn test_unregister() { - let mgr = PluginManager::default(); - let config = make_config("removable", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - mgr.register_handler::(plugin, config).unwrap(); - - assert_eq!(mgr.plugin_count(), 1); - mgr.unregister("removable"); - assert_eq!(mgr.plugin_count(), 0); - assert!(!mgr.has_hooks_for("test_hook")); - } - - /// Wraps the manager in `Arc` and dispatches concurrently from many - /// tasks. Also issues a `register_handler` call mid-flight to prove - /// that runtime registration is safe alongside invocations — the whole - /// point of the `ArcSwap`-based snapshot redesign. Before this fix, - /// `register_*` was `&mut self`, so this pattern wouldn't even compile. - #[tokio::test] - async fn test_manager_arc_shareable_with_concurrent_dispatch_and_registration() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - static INVOKE_COUNT: AtomicUsize = AtomicUsize::new(0); - INVOKE_COUNT.store(0, Ordering::SeqCst); - - struct CountingHandler; - #[async_trait] - impl AnyHookHandler for CountingHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - INVOKE_COUNT.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = Arc::new(PluginManager::default()); - - // Register an initial plugin and initialize. - let cfg = make_config("p0", 10, PluginMode::Sequential); - let plugin: Arc = Arc::new(AllowPlugin { cfg: cfg.clone() }); - let handler: Arc = Arc::new(CountingHandler); - mgr.register_raw::(plugin, cfg, handler).unwrap(); - mgr.initialize().await.unwrap(); - - // Spawn N concurrent invokers; midway, register a second plugin - // from a different task — the snapshot swaps under their feet. - let n = 16; - let mut handles = Vec::with_capacity(n + 1); - for i in 0..n { - let mgr = Arc::clone(&mgr); - handles.push(tokio::spawn(async move { - let payload: Box = Box::new(TestPayload { - value: format!("call-{}", i), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result.continue_processing); - })); - } - - // Concurrent registration — proves register_handler works through &Arc. - { - let mgr = Arc::clone(&mgr); - handles.push(tokio::spawn(async move { - let cfg = make_config("p1-late", 20, PluginMode::Sequential); - let plugin: Arc = Arc::new(AllowPlugin { cfg: cfg.clone() }); - let handler: Arc = Arc::new(CountingHandler); - mgr.register_raw::(plugin, cfg, handler).unwrap(); - })); - } - - for h in handles { - h.await.unwrap(); - } - - // At least the initial plugin ran for every invoke (some invokes - // may have raced past the registration and only seen the initial - // plugin; others may have seen both). The exact count depends on - // the race, but lower bound is `n` (one fire per invoke for p0). - assert!(INVOKE_COUNT.load(Ordering::SeqCst) >= n); - // Late registration is now visible. - assert_eq!(mgr.plugin_count(), 2); - } - - #[tokio::test] - async fn test_audit_plugin_cannot_block() { - let mgr = PluginManager::default(); - let config = make_config("audit-denier", 10, PluginMode::Audit); - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - // Audit mode — deny is suppressed, pipeline continues - assert!(result.continue_processing); - } - - #[tokio::test] - async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { - let mgr = PluginManager::default(); - - // Register an error handler with on_error: Disable - let config = - make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - // Also register a normal allow plugin (lower priority = runs second) - let config2 = make_config("allow-plugin", 20, PluginMode::Sequential); - let plugin2 = Arc::new(AllowPlugin { - cfg: config2.clone(), - }); - mgr.register_handler::(plugin2, config2) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // First invocation — flaky plugin errors, gets disabled, pipeline continues - // because on_error is Disable (not Fail). allow-plugin still runs. - let payload: Box = Box::new(TestPayload { - value: "first".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result.continue_processing); - - // Verify the plugin is now disabled - let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); - assert!(plugin_ref.is_disabled()); - assert_eq!(plugin_ref.mode(), PluginMode::Disabled); - - // Second invocation — flaky plugin should be skipped entirely - // (group_by_mode filters it out). Only allow-plugin runs. - let payload2: Box = Box::new(TestPayload { - value: "second".into(), - }); - let (result2, _) = mgr - .invoke_by_name("test_hook", payload2, Extensions::default(), None) - .await; - assert!(result2.continue_processing); - } - - #[tokio::test] - async fn test_on_error_ignore_continues_without_disabling() { - let mgr = PluginManager::default(); - - // Register an error handler with on_error: Ignore - let config = - make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // First invocation — plugin errors, ignored, pipeline continues - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result.continue_processing); - - // Plugin should NOT be disabled — still in its original mode - let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); - assert!(!plugin_ref.is_disabled()); - assert_eq!(plugin_ref.mode(), PluginMode::Sequential); - } - - /// Errors from `on_error: ignore` plugins must surface in - /// `PipelineResult.errors` so callers can see swallowed failures - /// programmatically — not just in log output. - #[tokio::test] - async fn test_on_error_ignore_records_in_pipeline_errors() { - let mgr = PluginManager::default(); - let config = - make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - // Pipeline continued (Ignore policy)… - assert!(result.continue_processing); - // …but the swallowed error is in result.errors with structured fields. - assert_eq!(result.errors.len(), 1, "expected one error record"); - let rec = &result.errors[0]; - assert_eq!(rec.plugin_name, "error-plugin"); - assert!( - rec.message.contains("simulated failure"), - "message lost: {}", - rec.message, - ); - } - - /// Errors from `on_error: disable` plugins must ALSO appear in - /// `PipelineResult.errors` (not just trip the circuit breaker). - #[tokio::test] - async fn test_on_error_disable_records_in_pipeline_errors() { - let mgr = PluginManager::default(); - let config = - make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert_eq!(result.errors.len(), 1); - // Plugin was also disabled (the Disable policy's other effect). - assert!(mgr.get_plugin("flaky-plugin").unwrap().is_disabled()); - } - - #[tokio::test] - async fn test_on_error_fail_halts_pipeline() { - let mgr = PluginManager::default(); - - // Register an error handler with on_error: Fail (default) - let config = - make_config_with_on_error("strict-plugin", 10, PluginMode::Sequential, OnError::Fail); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // Invocation — plugin errors, pipeline halts with a violation - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(!result.continue_processing); - assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error"); - assert_eq!( - result.violation.as_ref().unwrap().plugin_name.as_deref(), - Some("strict-plugin"), - ); - } - - // -- Additional test plugins -- - - /// Plugin that modifies the payload (for Transform mode testing). - struct TransformPlugin { - cfg: PluginConfig, - } - - #[async_trait] - impl Plugin for TransformPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - Ok(()) - } - } - - impl HookHandler for TransformPlugin { - async fn handle( - &self, - payload: &TestPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::modify_payload(TestPayload { - value: format!("{}_transformed", payload.value), - }) - } - } - - /// Handler that sleeps (for timeout and fire-and-forget testing). - struct SlowHandler { - delay_ms: u64, - } - - #[async_trait] - impl AnyHookHandler for SlowHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - // -- Bug-covering tests -- - - #[tokio::test] - async fn test_transform_modifies_payload() { - let mgr = PluginManager::default(); - let config = make_config("transformer", 10, PluginMode::Transform); - let plugin = Arc::new(TransformPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload = TestPayload { - value: "original".into(), - }; - - let (result, _) = mgr - .invoke::(payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert!( - result.payload_modified, - "the transform accepted a new payload, so the result must say so" - ); - let final_payload = result.modified_payload.unwrap(); - let typed = final_payload - .as_any() - .downcast_ref::() - .unwrap(); - assert_eq!(typed.value, "original_transformed"); - } - - /// `modified_payload` is `Some` on every allowed pipeline, carrying - /// the final payload whether or not a plugin touched it. Only - /// `payload_modified` distinguishes the two, so callers deciding - /// whether to forward a rewritten payload must read that. - #[tokio::test] - async fn allow_without_mutation_reports_payload_unmodified() { - let mgr = PluginManager::default(); - let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); - - let payload = TestPayload { - value: "original".into(), - }; - - let (result, _) = mgr - .invoke::(payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert!(result.modified_payload.is_some()); - assert!(!result.payload_modified); - } - - /// Transform phase is documented `can_block: No` (plugin.rs PluginMode - /// table). An `on_error: Fail` plugin error or timeout in Transform must - /// NOT halt the pipeline — non-blocking is non-blocking, regardless of - /// the plugin's stated on_error preference. Disable still works. - #[tokio::test] - async fn test_transform_on_error_fail_does_not_halt_pipeline() { - let mgr = PluginManager::default(); - let config = - make_config_with_on_error("flaky-transform", 10, PluginMode::Transform, OnError::Fail); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!( - result.continue_processing, - "Transform on_error:Fail must not halt the pipeline (phase is non-blocking)", - ); - assert!(result.violation.is_none()); - } - - /// Audit phase previously ignored `on_error` entirely, so an - /// `on_error: Disable` plugin would error forever without the circuit - /// breaker tripping. After the fix Audit honors Disable. - #[tokio::test] - async fn test_audit_on_error_disable_disables_plugin() { - let mgr = PluginManager::default(); - let config = - make_config_with_on_error("flaky-audit", 10, PluginMode::Audit, OnError::Disable); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - assert!(!mgr.get_plugin("flaky-audit").unwrap().is_disabled()); - - // Invoke once — handler errors, on_error=Disable, plugin must be - // disabled. Pipeline still returns success (Audit can't block). - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result.continue_processing); - - assert!( - mgr.get_plugin("flaky-audit").unwrap().is_disabled(), - "Audit phase must honor on_error:Disable", - ); - } - - #[tokio::test] - async fn test_concurrent_multiple_plugins_all_run() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - // Shared counter to prove both plugins actually ran - static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); - CALL_COUNT.store(0, Ordering::SeqCst); - - struct CountingHandler; - - #[async_trait] - impl AnyHookHandler for CountingHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - // Small sleep to ensure both tasks are spawned before either finishes - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - CALL_COUNT.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent); - let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); - let h1: Arc = Arc::new(CountingHandler); - mgr.register_raw::(p1, c1, h1).unwrap(); - - let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent); - let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); - let h2: Arc = Arc::new(CountingHandler); - mgr.register_raw::(p2, c2, h2).unwrap(); - - mgr.initialize().await.unwrap(); - - let start = std::time::Instant::now(); - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - let elapsed = start.elapsed(); - - assert!(result.continue_processing); - assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); - // If they ran in parallel, total time should be ~50ms, not ~100ms - assert!( - elapsed.as_millis() < 90, - "concurrent plugins ran serially: {}ms", - elapsed.as_millis() - ); - } - - /// A deny on one concurrent plugin should short-circuit the pipeline - /// AND cancel the slow plugin still running in another task. Previously - /// `join_all` waited for every task before noticing the deny, so - /// short_circuit_on_deny was a no-op in wall-clock terms and the slow - /// plugin completed its side effects after the pipeline returned. - #[tokio::test] - async fn test_concurrent_short_circuit_aborts_slow_plugin() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::Duration; - - static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0); - SLOW_COMPLETED.store(0, Ordering::SeqCst); - - struct DenyImmediately; - #[async_trait] - impl AnyHookHandler for DenyImmediately { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - let result: PluginResult = - PluginResult::deny(PluginViolation::new("denied", "fast deny")); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - struct SlowSideEffect; - #[async_trait] - impl AnyHookHandler for SlowSideEffect { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - tokio::time::sleep(Duration::from_secs(2)).await; - // If the task isn't aborted at the sleep's await point, - // this fetch_add fires after the pipeline already returned. - SLOW_COMPLETED.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - let cfg_deny = make_config("denier", 10, PluginMode::Concurrent); - let plugin_deny = Arc::new(AllowPlugin { - cfg: cfg_deny.clone(), - }); - mgr.register_raw::( - plugin_deny, - cfg_deny, - Arc::new(DenyImmediately) as Arc, - ) - .unwrap(); - - let cfg_slow = make_config("slow", 20, PluginMode::Concurrent); - let plugin_slow = Arc::new(AllowPlugin { - cfg: cfg_slow.clone(), - }); - mgr.register_raw::( - plugin_slow, - cfg_slow, - Arc::new(SlowSideEffect) as Arc, - ) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // Pipeline must return quickly — the deny short-circuits before - // the 2s sleep completes. - let start = std::time::Instant::now(); - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - let elapsed = start.elapsed(); - - assert!(!result.continue_processing); - assert!( - elapsed < Duration::from_millis(500), - "pipeline should short-circuit on deny, but took {}ms (slow plugin not aborted)", - elapsed.as_millis(), - ); - - // Wait long enough that the slow plugin's sleep would have finished - // if it hadn't been aborted, then verify its side effect didn't fire. - tokio::time::sleep(Duration::from_millis(2_500)).await; - assert_eq!( - SLOW_COMPLETED.load(Ordering::SeqCst), - 0, - "slow plugin's side effect ran after pipeline returned — task was not aborted", - ); - } - - /// short_circuit_on_deny=false: every concurrent plugin must run to - /// completion (no abort), and the earliest deny is returned at the end. - #[tokio::test] - async fn test_concurrent_no_short_circuit_runs_every_plugin() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - static ALLOW_RAN: AtomicUsize = AtomicUsize::new(0); - ALLOW_RAN.store(0, Ordering::SeqCst); - - struct DenyImmediately; - #[async_trait] - impl AnyHookHandler for DenyImmediately { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - let result: PluginResult = - PluginResult::deny(PluginViolation::new("denied", "fast deny")); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - struct AllowAndCount; - #[async_trait] - impl AnyHookHandler for AllowAndCount { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - ALLOW_RAN.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let config = ManagerConfig { - executor: crate::executor::ExecutorConfig { - timeout_seconds: 30, - short_circuit_on_deny: false, - }, - route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, - }; - let mgr = PluginManager::new(config); - - let cfg_deny = make_config("denier", 10, PluginMode::Concurrent); - let plugin_deny = Arc::new(AllowPlugin { - cfg: cfg_deny.clone(), - }); - mgr.register_raw::( - plugin_deny, - cfg_deny, - Arc::new(DenyImmediately) as Arc, - ) - .unwrap(); - - let cfg_allow = make_config("allow", 20, PluginMode::Concurrent); - let plugin_allow = Arc::new(AllowPlugin { - cfg: cfg_allow.clone(), - }); - mgr.register_raw::( - plugin_allow, - cfg_allow, - Arc::new(AllowAndCount) as Arc, - ) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - // Earliest deny is returned… - assert!(!result.continue_processing); - // …but the non-denying plugin must still have run (no abort). - assert_eq!(ALLOW_RAN.load(Ordering::SeqCst), 1); - } - - /// Plugin handler that panics inside its async invoke. With tokio::spawn, - /// the panic surfaces as a JoinError on the task's JoinHandle. - struct PanicHandler; - - #[async_trait] - impl AnyHookHandler for PanicHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - panic!("simulated panic in concurrent plugin task"); - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - /// A panicking concurrent plugin with `on_error: Fail` must halt the - /// pipeline with a violation. Previously the JoinError was just logged - /// and the panic was silently swallowed. - /// - /// Note: this test prints "thread 'tokio-runtime-worker' panicked at..." - /// to stderr — that's tokio reporting the captured panic. Expected. - #[tokio::test] - async fn test_concurrent_panic_with_on_error_fail_halts_pipeline() { - let mgr = PluginManager::default(); - - let cfg = - make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Fail); - let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() }); - let handler: Arc = Arc::new(PanicHandler); - mgr.register_raw::(plugin, cfg, handler).unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!( - !result.continue_processing, - "Fail must halt the pipeline on panic" - ); - let v = result.violation.as_ref().expect("expected violation"); - assert_eq!(v.code, "plugin_panic"); - assert_eq!(v.plugin_name.as_deref(), Some("panic-plugin")); - } - - /// A panicking concurrent plugin with `on_error: Disable` must trip - /// the plugin's circuit breaker so it's skipped on subsequent invokes. - /// A second non-panicking plugin in the same phase still runs. - #[tokio::test] - async fn test_concurrent_panic_with_on_error_disable_trips_circuit_breaker() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - static SURVIVOR_CALLS: AtomicUsize = AtomicUsize::new(0); - SURVIVOR_CALLS.store(0, Ordering::SeqCst); - - struct SurvivorHandler; - #[async_trait] - impl AnyHookHandler for SurvivorHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - SURVIVOR_CALLS.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - let panic_cfg = - make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Disable); - let panic_plugin = Arc::new(AllowPlugin { - cfg: panic_cfg.clone(), - }); - let panic_handler: Arc = Arc::new(PanicHandler); - mgr.register_raw::(panic_plugin, panic_cfg, panic_handler) - .unwrap(); - - let survivor_cfg = make_config("survivor", 20, PluginMode::Concurrent); - let survivor_plugin = Arc::new(AllowPlugin { - cfg: survivor_cfg.clone(), - }); - let survivor_handler: Arc = Arc::new(SurvivorHandler); - mgr.register_raw::(survivor_plugin, survivor_cfg, survivor_handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // First invoke — panic plugin panics, gets disabled. Survivor still runs. - let payload: Box = Box::new(TestPayload { value: "1".into() }); - let (result1, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!( - result1.continue_processing, - "Disable must not halt the pipeline" - ); - assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 1); - assert!( - mgr.get_plugin("panic-plugin").unwrap().is_disabled(), - "panic plugin must be disabled after the panic", - ); - - // Second invoke — disabled plugin is skipped, doesn't panic again. - let payload2: Box = Box::new(TestPayload { value: "2".into() }); - let (result2, _) = mgr - .invoke_by_name("test_hook", payload2, Extensions::default(), None) - .await; - assert!(result2.continue_processing); - // Survivor ran a second time; panic plugin did not. - assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn test_timeout_fires_on_slow_handler() { - let config = ManagerConfig { - executor: crate::executor::ExecutorConfig { - timeout_seconds: 1, - short_circuit_on_deny: true, - }, - route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, - }; - let mgr = PluginManager::new(config); - - // Register a handler that sleeps longer than the timeout - let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: plugin_config.clone(), - }); - let handler: Arc = Arc::new(SlowHandler { delay_ms: 5000 }); - mgr.register_raw::(plugin, plugin_config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let start = std::time::Instant::now(); - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - let elapsed = start.elapsed(); - - // Should have timed out and denied (on_error: Fail) - assert!(!result.continue_processing); - assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); - // Should have returned in ~1s, not 5s - assert!( - elapsed.as_secs() < 3, - "timeout didn't fire: {}s", - elapsed.as_secs() - ); - } - - #[tokio::test] - async fn test_fire_and_forget_returns_before_task_completes() { - use std::sync::atomic::{AtomicBool, Ordering}; - - static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); - TASK_COMPLETED.store(false, Ordering::SeqCst); - - struct SlowFireAndForgetHandler; - - #[async_trait] - impl AnyHookHandler for SlowFireAndForgetHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - TASK_COMPLETED.store(true, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - let config = make_config("fire-forget", 10, PluginMode::FireAndForget); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(SlowFireAndForgetHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, bg) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - // Pipeline should return immediately — before the background task finishes - assert!(result.continue_processing); - assert!( - !TASK_COMPLETED.load(Ordering::SeqCst), - "fire-and-forget task completed before pipeline returned" - ); - - // Wait for background tasks using wait_for_background_tasks() - let errors = bg.wait_for_background_tasks().await; - assert!( - errors.is_empty(), - "background task had errors: {:?}", - errors - ); - assert!( - TASK_COMPLETED.load(Ordering::SeqCst), - "fire-and-forget task never completed" - ); - } - - /// `shutdown()` must wait for in-flight fire-and-forget tasks to drain - /// before returning, so audit / telemetry plugins that flush at the - /// end of a request lifetime aren't cancelled mid-write. The caller - /// drops `BackgroundTasks` (the common case for fire-and-forget), - /// so the only way the manager knows about the in-flight task is the - /// internal `TaskTracker`. - #[tokio::test] - async fn test_shutdown_drains_in_flight_fire_and_forget_tasks() { - use std::sync::atomic::{AtomicBool, Ordering}; - - static FAF_COMPLETED: AtomicBool = AtomicBool::new(false); - FAF_COMPLETED.store(false, Ordering::SeqCst); - - struct SlowFafHandler; - #[async_trait] - impl AnyHookHandler for SlowFafHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - tokio::time::sleep(std::time::Duration::from_millis(150)).await; - FAF_COMPLETED.store(true, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - let config = make_config("slow-faf", 10, PluginMode::FireAndForget); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(SlowFafHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - mgr.initialize().await.unwrap(); - - // Invoke and drop BackgroundTasks immediately — simulating the - // common case where the caller doesn't explicitly wait for FAF. - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (_result, _bg_dropped) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - // Task should still be in flight (sleeping 150ms). - assert!(!FAF_COMPLETED.load(Ordering::SeqCst)); - - // shutdown() must drain in-flight FAF tasks before returning. - mgr.shutdown().await; - - // After shutdown, the FAF task must have run to completion. - assert!( - FAF_COMPLETED.load(Ordering::SeqCst), - "shutdown returned before fire-and-forget task finished — task was abandoned", - ); - } - - #[tokio::test] - async fn test_global_state_flows_between_serial_plugins() { - // Plugin A writes to global_state; Plugin B reads it. - - struct WriterHandler; - - #[async_trait] - impl AnyHookHandler for WriterHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, Box> { - ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - struct ReaderHandler { - saw_writer: std::sync::Arc, - } - - #[async_trait] - impl AnyHookHandler for ReaderHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, Box> { - if ctx.get_global("writer_was_here").is_some() { - self.saw_writer - .store(true, std::sync::atomic::Ordering::SeqCst); - } - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let mgr = PluginManager::default(); - - // Writer runs first (priority 10) - let c1 = make_config("writer", 10, PluginMode::Sequential); - let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); - let h1: Arc = Arc::new(WriterHandler); - mgr.register_raw::(p1, c1, h1).unwrap(); - - // Reader runs second (priority 20) - let c2 = make_config("reader", 20, PluginMode::Sequential); - let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); - let h2: Arc = Arc::new(ReaderHandler { - saw_writer: saw_writer.clone(), - }); - mgr.register_raw::(p2, c2, h2).unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert!( - saw_writer.load(std::sync::atomic::Ordering::SeqCst), - "reader plugin did not see writer's global_state change" - ); - } - - #[tokio::test] - async fn test_local_state_persists_across_hook_invocations() { - // Plugin writes to local_state on first hook call. - // Context table is threaded into second call — local_state preserved. - - struct LocalWriterHandler; - - #[async_trait] - impl AnyHookHandler for LocalWriterHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, Box> { - let count = ctx - .get_local("call_count") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - ctx.set_local("call_count", serde_json::Value::from(count + 1)); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - let config = make_config("counter", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(LocalWriterHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // First invocation — no context table, starts fresh - let payload: Box = Box::new(TestPayload { - value: "first".into(), - }); - let (result1, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result1.continue_processing); - - // Check call_count = 1 in the returned context table - let table = &result1.context_table; - let local = table - .local_states - .values() - .next() - .expect("context table should have one local_state entry"); - assert_eq!(local.get("call_count").unwrap().as_u64().unwrap(), 1); - - // Second invocation — pass the context table from the first call - let payload2: Box = Box::new(TestPayload { - value: "second".into(), - }); - let (result2, _) = mgr - .invoke_by_name( - "test_hook", - payload2, - Extensions::default(), - Some(result1.context_table), - ) - .await; - assert!(result2.continue_processing); - - // call_count should now be 2 — local_state persisted across invocations - let table2 = &result2.context_table; - let local2 = table2 - .local_states - .values() - .next() - .expect("context table should have one local_state entry"); - assert_eq!(local2.get("call_count").unwrap().as_u64().unwrap(), 2); - } - - /// global_state writes by an earlier plugin must be visible to a later - /// plugin in the same serial phase, and the canonical state on the - /// returned context_table must reflect every plugin's contribution in - /// priority order. Previously this relied on `ctx_table.values().last()` - /// (HashMap iteration order — non-deterministic). - #[tokio::test] - async fn test_global_state_propagates_in_priority_order() { - /// Handler that appends `tag` to global_state["chain"] (creating - /// an array if absent). After running, the array reveals the - /// observed run order from each plugin's perspective. - struct GlobalChainHandler { - tag: &'static str, - } - - #[async_trait] - impl AnyHookHandler for GlobalChainHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, Box> { - let mut chain = ctx - .get_global("chain") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - chain.push(serde_json::Value::String(self.tag.into())); - ctx.set_global("chain", serde_json::Value::Array(chain)); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - // Plugin A — priority 10 (runs first) - let cfg_a = make_config("plugin_a", 10, PluginMode::Sequential); - let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() }); - let handler_a: Arc = Arc::new(GlobalChainHandler { tag: "a" }); - mgr.register_raw::(plugin_a, cfg_a, handler_a) - .unwrap(); - - // Plugin B — priority 20 (runs second) - let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential); - let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() }); - let handler_b: Arc = Arc::new(GlobalChainHandler { tag: "b" }); - mgr.register_raw::(plugin_b, cfg_b, handler_b) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result.continue_processing); - - // Canonical global_state on the returned table must contain both - // contributions in priority order — proving plugin B observed plugin - // A's write, and the table holds the merged result, not an arbitrary - // plugin's snapshot. - let chain = result - .context_table - .global_state - .get("chain") - .and_then(|v| v.as_array()) - .expect("global_state.chain should be an array"); - let tags: Vec<&str> = chain.iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(tags, vec!["a", "b"]); - } - - /// All five phases (Sequential, Transform, Audit, Concurrent, - /// FireAndForget) execute in the documented order, with payload - /// modifications from earlier phases visible in later ones. Closes - /// the review's "no multi-phase combination test" gap. - #[tokio::test] - async fn test_all_five_phases_run_in_order_with_payload_chaining() { - use std::sync::Arc as StdArc; - use std::sync::Mutex as StdMutex; - - let log: StdArc>> = StdArc::new(StdMutex::new(Vec::new())); - - // Sequential — modifies payload, logs "seq". - struct SeqHandler { - log: StdArc>>, - } - #[async_trait] - impl AnyHookHandler for SeqHandler { - async fn invoke( - &self, - payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - self.log.lock().unwrap().push("seq"); - let typed = payload.as_any().downcast_ref::().unwrap(); - let modified = TestPayload { - value: format!("{}|seq", typed.value), - }; - let result: PluginResult = PluginResult::modify_payload(modified); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - // Transform — modifies payload, logs "transform". - struct TransformLogger { - log: StdArc>>, - } - #[async_trait] - impl AnyHookHandler for TransformLogger { - async fn invoke( - &self, - payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - self.log.lock().unwrap().push("transform"); - let typed = payload.as_any().downcast_ref::().unwrap(); - let modified = TestPayload { - value: format!("{}|transform", typed.value), - }; - let result: PluginResult = PluginResult::modify_payload(modified); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - // Logger that asserts the payload it observes contains both prior - // phases' marks (proving payload chaining made it this far). - struct ObserverHandler { - tag: &'static str, - log: StdArc>>, - expected_payload: &'static str, - } - #[async_trait] - impl AnyHookHandler for ObserverHandler { - async fn invoke( - &self, - payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - let typed = payload.as_any().downcast_ref::().unwrap(); - assert_eq!( - typed.value, self.expected_payload, - "{} observed unexpected payload: got '{}', expected '{}'", - self.tag, typed.value, self.expected_payload, - ); - self.log.lock().unwrap().push(self.tag); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let mgr = PluginManager::default(); - - let cfg_seq = make_config("seq", 10, PluginMode::Sequential); - mgr.register_raw::( - Arc::new(AllowPlugin { - cfg: cfg_seq.clone(), - }), - cfg_seq, - Arc::new(SeqHandler { - log: StdArc::clone(&log), - }), - ) - .unwrap(); - - let cfg_transform = make_config("transform", 10, PluginMode::Transform); - mgr.register_raw::( - Arc::new(AllowPlugin { - cfg: cfg_transform.clone(), - }), - cfg_transform, - Arc::new(TransformLogger { - log: StdArc::clone(&log), - }), - ) - .unwrap(); - - let cfg_audit = make_config("audit", 10, PluginMode::Audit); - mgr.register_raw::( - Arc::new(AllowPlugin { - cfg: cfg_audit.clone(), - }), - cfg_audit, - Arc::new(ObserverHandler { - tag: "audit", - log: StdArc::clone(&log), - expected_payload: "start|seq|transform", - }), - ) - .unwrap(); - - let cfg_concurrent = make_config("concurrent", 10, PluginMode::Concurrent); - mgr.register_raw::( - Arc::new(AllowPlugin { - cfg: cfg_concurrent.clone(), - }), - cfg_concurrent, - Arc::new(ObserverHandler { - tag: "concurrent", - log: StdArc::clone(&log), - expected_payload: "start|seq|transform", - }), - ) - .unwrap(); - - let cfg_faf = make_config("faf", 10, PluginMode::FireAndForget); - mgr.register_raw::( - Arc::new(AllowPlugin { - cfg: cfg_faf.clone(), - }), - cfg_faf, - Arc::new(ObserverHandler { - tag: "faf", - log: StdArc::clone(&log), - expected_payload: "start|seq|transform", - }), - ) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "start".into(), - }); - let (result, bg) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - // Final payload should have both modify-phase marks. - let final_payload = result.modified_payload.unwrap(); - let typed = final_payload - .as_any() - .downcast_ref::() - .unwrap(); - assert_eq!(typed.value, "start|seq|transform"); - - // Drain the FAF task before checking ordering — its log entry - // races the rest of the function otherwise. - let _ = bg.wait_for_background_tasks().await; - - let log = log.lock().unwrap(); - // Sequential, Transform, Audit are guaranteed in order (serial phases). - assert_eq!(log[0], "seq", "first should be sequential phase"); - assert_eq!(log[1], "transform", "second should be transform phase"); - assert_eq!(log[2], "audit", "third should be audit phase"); - // Concurrent runs before invoke returns; FAF was waited on above. - // Their relative order with each other is not strictly guaranteed - // (FAF spawns *after* concurrent finishes, but tokio scheduling - // can interleave). Just check both present in indices 3 / 4. - let post_audit: std::collections::HashSet<&&'static str> = log[3..].iter().collect(); - assert!( - post_audit.contains(&"concurrent"), - "concurrent phase must run" - ); - assert!(post_audit.contains(&"faf"), "fire-and-forget must run"); - assert_eq!(log.len(), 5, "all five phases should have logged"); - } - - /// Routing must work for `resource`, `prompt`, and `llm` entity types - /// — not just `tool`. Closes the review's "no test verifying entity - /// types other than tool in routing" gap. - #[tokio::test] - async fn test_routing_works_for_all_entity_types() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc as StdArc; - - // One counter per entity-type test; each plugin only fires when - // the route resolves to it. - struct CountHandler { - counter: StdArc, - } - #[async_trait] - impl AnyHookHandler for CountHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - self.counter.fetch_add(1, Ordering::SeqCst); - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - // Each row: (entity_type, route field name, route value, request entity_name, should_match) - // We build a fresh manager per entity type so routes don't bleed. - for (entity_type, route_field, route_value, request_name, should_match) in [ - ("resource", "resource", "my_resource", "my_resource", true), - ( - "resource", - "resource", - "my_resource", - "other_resource", - false, - ), - ("prompt", "prompt", "my_prompt", "my_prompt", true), - ("prompt", "prompt", "my_prompt", "other_prompt", false), - ("llm", "llm", "gpt-4", "gpt-4", true), - ("llm", "llm", "gpt-4", "claude", false), - ] { - let yaml = format!( - r#" -plugin_settings: - routing_enabled: true -plugins: - - name: target - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - {route_field}: {route_value} - plugins: - - target -"# - ); - let cpex_config = crate::config::parse_config(&yaml).unwrap(); - - let mgr = PluginManager::default(); - let counter = StdArc::new(AtomicUsize::new(0)); - // Custom factory that hands out a CountHandler with our shared counter. - struct ParamFactory(StdArc); - impl crate::factory::PluginFactory for ParamFactory { - fn create( - &self, - config: &PluginConfig, - ) -> Result> { - Ok(crate::factory::PluginInstance { - plugin: Arc::new(AllowPlugin { - cfg: config.clone(), - }), - handlers: vec![( - "test_hook", - Arc::new(CountHandler { - counter: StdArc::clone(&self.0), - }), - )], - }) - } - } - mgr.register_factory( - "test/allow", - Box::new(ParamFactory(StdArc::clone(&counter))), - ); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - let p: Box = Box::new(TestPayload { value: "x".into() }); - let ext = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some(entity_type.into()), - entity_name: Some(request_name.into()), - ..Default::default() - })), - ..Default::default() - }; - let _ = mgr.invoke_by_name("test_hook", p, ext, None).await; - - let expected = if should_match { 1 } else { 0 }; - assert_eq!( - counter.load(Ordering::SeqCst), - expected, - "entity_type={} route_field={} route_value={} request_name={} expected fire={}", - entity_type, - route_field, - route_value, - request_name, - should_match, - ); - } - } - - /// `initialize()` must roll back already-initialized plugins by - /// calling `shutdown()` on each, in reverse order, when a later - /// plugin's `initialize()` fails. Closes the review's "no test for - /// initialize() rollback path" gap. - #[tokio::test] - async fn test_initialize_rollback_on_failure() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc as StdArc; - - // Track per-plugin init / shutdown invocations. - let init_count_a = StdArc::new(AtomicUsize::new(0)); - let shutdown_count_a = StdArc::new(AtomicUsize::new(0)); - let init_count_b = StdArc::new(AtomicUsize::new(0)); - let shutdown_count_b = StdArc::new(AtomicUsize::new(0)); - let init_count_c = StdArc::new(AtomicUsize::new(0)); - let shutdown_count_c = StdArc::new(AtomicUsize::new(0)); - - struct LifecyclePlugin { - cfg: PluginConfig, - init_counter: StdArc, - shutdown_counter: StdArc, - fail_init: bool, - } - #[async_trait] - impl Plugin for LifecyclePlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - self.init_counter.fetch_add(1, Ordering::SeqCst); - if self.fail_init { - Err(Box::new(PluginError::Config { - message: "intentional init failure".into(), - })) - } else { - Ok(()) - } - } - async fn shutdown(&self) -> Result<(), Box> { - self.shutdown_counter.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - } - impl HookHandler for LifecyclePlugin { - async fn handle( - &self, - _payload: &TestPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } - } - - let mgr = PluginManager::default(); - - // Plugin A: initializes successfully (priority 10, registered first). - let cfg_a = make_config("a", 10, PluginMode::Sequential); - let plugin_a = Arc::new(LifecyclePlugin { - cfg: cfg_a.clone(), - init_counter: StdArc::clone(&init_count_a), - shutdown_counter: StdArc::clone(&shutdown_count_a), - fail_init: false, - }); - mgr.register_handler::(plugin_a, cfg_a) - .unwrap(); - - // Plugin B: initialize() returns Err — should trigger rollback. - let cfg_b = make_config("b", 20, PluginMode::Sequential); - let plugin_b = Arc::new(LifecyclePlugin { - cfg: cfg_b.clone(), - init_counter: StdArc::clone(&init_count_b), - shutdown_counter: StdArc::clone(&shutdown_count_b), - fail_init: true, - }); - mgr.register_handler::(plugin_b, cfg_b) - .unwrap(); - - // Plugin C: never reached (init aborts at B). - let cfg_c = make_config("c", 30, PluginMode::Sequential); - let plugin_c = Arc::new(LifecyclePlugin { - cfg: cfg_c.clone(), - init_counter: StdArc::clone(&init_count_c), - shutdown_counter: StdArc::clone(&shutdown_count_c), - fail_init: false, - }); - mgr.register_handler::(plugin_c, cfg_c) - .unwrap(); - - let result = mgr.initialize().await; - assert!( - result.is_err(), - "initialize() must propagate the init failure" - ); - - // The registry iterates plugins in `HashMap` order, which is - // randomized — so we don't know whether A and C were reached - // before B failed. The rollback invariants are order-independent: - // - // - For non-failing plugins (A, C): if init() was called, shutdown() - // must have been called too (rolled back). If init() was not - // called (B happened to iterate first), shutdown() shouldn't - // have either. In both cases, init_count == shutdown_count. - // - B's init() was called and failed, so its shutdown() must NOT - // run — failed-init plugins are not part of the rollback set. - let assert_pair_invariant = - |init: &AtomicUsize, shutdown: &AtomicUsize, tag: &str| { - let i = init.load(Ordering::SeqCst); - let s = shutdown.load(Ordering::SeqCst); - assert!( - (i == 0 && s == 0) || (i == 1 && s == 1), - "{}: init/shutdown should be paired (both 0 or both 1), got init={} shutdown={}", - tag, i, s, - ); - }; - assert_pair_invariant(&init_count_a, &shutdown_count_a, "A"); - assert_pair_invariant(&init_count_c, &shutdown_count_c, "C"); - - // B specifically: init was called and failed; no shutdown for it. - assert_eq!( - init_count_b.load(Ordering::SeqCst), - 1, - "B's initialize was called", - ); - assert_eq!( - shutdown_count_b.load(Ordering::SeqCst), - 0, - "B failed to initialize; shutdown should not run for it", - ); - - // Manager must report not-initialized after the failure. - assert!(!mgr.is_initialized()); - } - - // -- Factory-based tests -- - - /// A test factory that creates AllowPlugin instances. - struct AllowPluginFactory; - - impl crate::factory::PluginFactory for AllowPluginFactory { - fn create( - &self, - config: &PluginConfig, - ) -> Result> { - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = - Arc::new(TypedHandlerAdapter::::new( - Arc::clone(&plugin), - )); - Ok(crate::factory::PluginInstance { - plugin, - handlers: vec![("test_hook", handler)], - }) - } - } - - /// A test factory that creates DenyPlugin instances. - struct DenyPluginFactory; - - impl crate::factory::PluginFactory for DenyPluginFactory { - fn create( - &self, - config: &PluginConfig, - ) -> Result> { - let plugin = Arc::new(DenyPlugin { - cfg: config.clone(), - }); - let handler: Arc = - Arc::new(TypedHandlerAdapter::::new( - Arc::clone(&plugin), - )); - Ok(crate::factory::PluginInstance { - plugin, - handlers: vec![("test_hook", handler)], - }) - } - } - - #[tokio::test] - async fn test_from_config_creates_manager() { - let yaml = r#" -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 10 - -plugin_settings: - plugin_timeout: 60 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - assert_eq!(mgr.plugin_count(), 1); - assert!(mgr.has_hooks_for("test_hook")); - } - - #[tokio::test] - async fn test_from_config_invokes_correctly() { - let yaml = r#" -plugins: - - name: denier - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 10 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/deny", Box::new(DenyPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - // context_table = None (first invocation) - - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(!result.continue_processing); - assert_eq!(result.violation.as_ref().unwrap().code, "denied"); - } - - #[tokio::test] - async fn test_from_config_unknown_kind_rejected() { - let yaml = r#" -plugins: - - name: mystery - kind: unknown/type - hooks: [test_hook] -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let factories = PluginFactoryRegistry::new(); // empty — no factories - - let result = PluginManager::from_config(cpex_config, &factories); - match result { - Err(e) => assert!( - e.to_string().contains("no factory registered"), - "got: {}", - e - ), - Ok(_) => panic!("expected error for unknown kind"), - } - } - - #[tokio::test] - async fn test_from_config_multiple_plugins() { - let yaml = r#" -plugins: - - name: gate - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 5 - - name: fallback - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 10 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - factories.register("test/deny", Box::new(DenyPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - assert_eq!(mgr.plugin_count(), 2); - - // Deny plugin has higher priority (5 < 10), so it fires first - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - // context_table = None (first invocation) - - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(!result.continue_processing); // gate denied before fallback could allow - } - - // -- Routing cache tests -- - - #[tokio::test] - async fn test_routing_cache_populated_on_first_invoke() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 10 -routes: - - tool: get_compensation -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - assert_eq!(mgr.routing_cache_size(), 0); - - // First invoke — populates cache - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let ext = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - // context_table = None (first invocation) - mgr.invoke_by_name("test_hook", payload, ext, None).await; - - assert_eq!(mgr.routing_cache_size(), 1); - - // Second invoke — cache hit, still size 1 - let payload2: Box = Box::new(TestPayload { - value: "test2".into(), - }); - let ext2 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", payload2, ext2, None).await; - - assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry - } - - /// Regression (typed path): `load_config_yaml` used to deserialize - /// `CpexConfig` directly and skip `parse_config`'s normalization, so a - /// top-level `groups:` bundle never folded into `global.policies` and a - /// route joining it lost the group's plugins. Here the deny plugin lives - /// ONLY in the group — if it isn't folded into resolution, nothing runs - /// and the call is (wrongly) allowed. - #[tokio::test] - async fn load_config_yaml_folds_top_level_group_into_route_resolution() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: gate - kind: test/deny - hooks: [test_hook] - mode: sequential -groups: - hr-tools: - plugins: [gate] -routes: - - tool: get_compensation - groups: hr-tools -"#; - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config_yaml(yaml).expect("config must load"); - - let ext = Extensions { - meta: Some(Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - let payload: Box = Box::new(TestPayload { value: "x".into() }); - let (result, _bg) = mgr.invoke_by_name("test_hook", payload, ext, None).await; - - assert!( - !result.continue_processing, - "route must resolve the top-level group's plugin and deny; it was allowed, \ - so the group wasn't folded into the load path", - ); - assert_eq!(result.violation.as_ref().unwrap().code, "denied"); - } - - /// Regression (visitor path): the visitor walk read only - /// `global.policies`, so a top-level `groups:` bundle's `authorization:` - /// was never compiled. This registers a visitor that records which - /// bundles it was asked to compile and asserts the top-level group is - /// among them. - #[test] - fn load_config_yaml_compiles_top_level_group_via_visitor() { - use crate::visitor::{ConfigVisitor, VisitorError}; - use std::sync::Mutex as StdMutex; - - #[derive(Default)] - struct RecordingVisitor { - bundles: StdMutex>, - } - impl ConfigVisitor for RecordingVisitor { - fn name(&self) -> &str { - "recording" - } - fn visit_policy_bundle( - &self, - _mgr: &Arc, - tag: &str, - _yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - self.bundles.lock().unwrap().push(tag.to_string()); - Ok(()) - } - } - - let yaml = r#" -plugin_settings: - routing_enabled: true -groups: - hr-tools: - authorization: - pre_invocation: - - "require(role.hr)" -routes: - - tool: get_compensation - groups: hr-tools -"#; - let mgr = Arc::new(PluginManager::default()); - let recorder = Arc::new(RecordingVisitor::default()); - mgr.register_visitor(recorder.clone()); - mgr.load_config_yaml(yaml).expect("config must load"); - - let seen = recorder.bundles.lock().unwrap(); - assert!( - seen.iter().any(|b| b == "hr-tools"), - "top-level groups: bundle must be visited for compilation; saw: {seen:?}", - ); - } - - #[tokio::test] - async fn test_routing_cache_different_entities_separate() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - tool: get_compensation - - tool: send_email -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // Invoke for get_compensation - let p1: Box = Box::new(TestPayload { value: "t".into() }); - let e1 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", p1, e1, None).await; - - // Invoke for send_email - let p2: Box = Box::new(TestPayload { value: "t".into() }); - let e2 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("send_email".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", p2, e2, None).await; - - assert_eq!(mgr.routing_cache_size(), 2); - } - - #[tokio::test] - async fn test_routing_cache_cleared() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - tool: get_compensation -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - let payload: Box = Box::new(TestPayload { value: "t".into() }); - let ext = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", payload, ext, None).await; - assert_eq!(mgr.routing_cache_size(), 1); - - mgr.clear_routing_cache(); - assert_eq!(mgr.routing_cache_size(), 0); - } - - #[tokio::test] - async fn test_unregister_invalidates_routing_cache() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - tool: get_compensation -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "t".into() }); - let ext = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", payload, ext, None).await; - assert_eq!(mgr.routing_cache_size(), 1); - - // Unregister should invalidate the cache so removed plugins - // don't continue firing from stale cached entries. - mgr.unregister("allow_plugin"); - assert_eq!(mgr.routing_cache_size(), 0); - } - - #[test] - fn test_routing_cache_recovers_from_poisoned_lock() { - // A panic while holding the cache lock poisons it. Before the fix, - // every subsequent read()/write() would unwrap a PoisonError and - // panic, permanently breaking dispatch. With unwrap_or_else + - // into_inner, the cache stays usable. - // - // Note: this test intentionally panics inside catch_unwind, which - // prints "thread 'manager::tests::...' panicked at..." to test - // output even though the panic is caught. That's expected. - use std::panic::AssertUnwindSafe; - - let mgr = PluginManager::default(); - - let result = std::panic::catch_unwind(AssertUnwindSafe(|| { - let _guard = mgr.route_cache.write().unwrap(); - panic!("simulated panic while holding cache lock"); - })); - assert!(result.is_err(), "expected the panic to be caught"); - assert!( - mgr.route_cache.is_poisoned(), - "lock should be poisoned after the panic", - ); - - // All four lock sites must now succeed despite the poison flag. - assert_eq!(mgr.routing_cache_size(), 0); - mgr.clear_routing_cache(); - assert_eq!(mgr.routing_cache_size(), 0); - } - - #[tokio::test] - async fn test_routing_cache_rejects_inserts_at_capacity() { - // Cap of 2 — verifies bound holds AND uncached requests still resolve correctly. - let yaml = r#" -plugin_settings: - routing_enabled: true - route_cache_max_entries: 2 -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - tool: a - - tool: b - - tool: c -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - let invoke_for = |entity: &'static str| -> (Box, Extensions) { - let p: Box = Box::new(TestPayload { - value: entity.into(), - }); - let e = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some(entity.into()), - ..Default::default() - })), - ..Default::default() - }; - (p, e) - }; - - // Fill to cap (2 distinct entities). - let (p1, e1) = invoke_for("a"); - let (r1, _) = mgr.invoke_by_name("test_hook", p1, e1, None).await; - assert!(r1.continue_processing); - assert_eq!(mgr.routing_cache_size(), 1); - - let (p2, e2) = invoke_for("b"); - let (r2, _) = mgr.invoke_by_name("test_hook", p2, e2, None).await; - assert!(r2.continue_processing); - assert_eq!(mgr.routing_cache_size(), 2); - - // Third entity — cache is full, insert is rejected. - // Pipeline must still run correctly (slow path resolves the route). - let (p3, e3) = invoke_for("c"); - let (r3, _) = mgr.invoke_by_name("test_hook", p3, e3, None).await; - assert!( - r3.continue_processing, - "slow path must still resolve when cache is full" - ); - assert_eq!(mgr.routing_cache_size(), 2, "cache must not exceed cap"); - - // Repeated request for the same uncached entity also works. - let (p4, e4) = invoke_for("c"); - let (r4, _) = mgr.invoke_by_name("test_hook", p4, e4, None).await; - assert!(r4.continue_processing); - assert_eq!(mgr.routing_cache_size(), 2); - - // Clearing the cache lets new entries memoize again. - mgr.clear_routing_cache(); - let (p5, e5) = invoke_for("c"); - mgr.invoke_by_name("test_hook", p5, e5, None).await; - assert_eq!(mgr.routing_cache_size(), 1); - } - - #[tokio::test] - async fn test_register_handler_invalidates_routing_cache() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - tool: get_compensation -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { value: "t".into() }); - let ext = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", payload, ext, None).await; - assert_eq!(mgr.routing_cache_size(), 1); - - // Registering a new handler must invalidate the cache so the - // new plugin is visible to subsequent route resolutions. - let extra_cfg = make_config("late_plugin", 20, PluginMode::Sequential); - let extra = Arc::new(AllowPlugin { - cfg: extra_cfg.clone(), - }); - mgr.register_handler::(extra, extra_cfg) - .unwrap(); - assert_eq!(mgr.routing_cache_size(), 0); - } - - #[tokio::test] - async fn test_routing_cache_scope_creates_separate_entries() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allow_plugin] -plugins: - - name: allow_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential -routes: - - tool: get_compensation -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); - factories.register("test/allow", Box::new(AllowPluginFactory)); - - let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // Same entity, different scopes → separate cache entries - let p1: Box = Box::new(TestPayload { value: "t".into() }); - let e1 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - scope: Some("hr-server".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", p1, e1, None).await; - - let p2: Box = Box::new(TestPayload { value: "t".into() }); - let e2 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - scope: Some("billing-server".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", p2, e2, None).await; - - assert_eq!(mgr.routing_cache_size(), 2); // different scopes → different cache entries - } - - // -- Override instance tests -- - - #[tokio::test] - async fn test_route_override_creates_new_instance() { - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: rate_limiter - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 10 - config: - max_requests: 100 -routes: - - tool: get_compensation - plugins: - - rate_limiter: - config: - max_requests: 10 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - // Use register_factory + load_config so manager owns factories - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // Invoke with routing — should create override instance - let payload: Box = Box::new(TestPayload { value: "t".into() }); - let ext = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - // context_table = None (first invocation) - - let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; - - // Plugin executed (allow plugin returns allowed) - assert!(result.continue_processing); - // Cache populated - assert_eq!(mgr.routing_cache_size(), 1); - } - - /// Override instances must have `initialize()` called so plugins that - /// open DB connections / file handles / network clients on init don't - /// run with default state. Uses a tracking factory whose plugin - /// increments a counter inside its `initialize()`. - #[tokio::test] - async fn test_route_override_initializes_new_instance() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - static INIT_COUNT: AtomicUsize = AtomicUsize::new(0); - INIT_COUNT.store(0, Ordering::SeqCst); - - struct InitTrackingPlugin { - cfg: PluginConfig, - } - - #[async_trait] - impl Plugin for InitTrackingPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - INIT_COUNT.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - Ok(()) - } - } - - impl HookHandler for InitTrackingPlugin { - async fn handle( - &self, - _payload: &TestPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::allow() - } - } - - struct InitTrackingFactory; - impl crate::factory::PluginFactory for InitTrackingFactory { - fn create( - &self, - config: &PluginConfig, - ) -> Result> { - let plugin = Arc::new(InitTrackingPlugin { - cfg: config.clone(), - }); - let handler: Arc = - Arc::new(TypedHandlerAdapter::::new( - Arc::clone(&plugin), - )); - Ok(crate::factory::PluginInstance { - plugin, - handlers: vec![("test_hook", handler)], - }) - } - } - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: tracker - kind: test/init_tracking - hooks: [test_hook] - mode: sequential - priority: 10 - config: - max_requests: 100 -routes: - - tool: get_compensation - plugins: - - tracker: - config: - max_requests: 10 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - let mgr = PluginManager::default(); - mgr.register_factory("test/init_tracking", Box::new(InitTrackingFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // Base plugin was initialized exactly once during mgr.initialize(). - assert_eq!(INIT_COUNT.load(Ordering::SeqCst), 1); - - // Invoke with route override — creates a new instance via factory. - // That new instance must also be initialized. - let payload: Box = Box::new(TestPayload { value: "t".into() }); - let (result, _) = mgr - .invoke_by_name( - "test_hook", - payload, - make_meta("tool", "get_compensation", None, &[]), - None, - ) - .await; - assert!(result.continue_processing); - - assert_eq!( - INIT_COUNT.load(Ordering::SeqCst), - 2, - "override instance must have initialize() called", - ); - } - - /// Override and base must have INDEPENDENT circuit breakers. A failure - /// on an override-only route (e.g., bad credentials in the merged - /// config) must not silently disable the plugin for every other route - /// using the base config — config is part of the failure surface, and - /// per-route blast radius is the point of having overrides. - #[tokio::test] - async fn test_route_override_circuit_breaker_isolated_from_base() { - struct ErrorOnInvokeFactory; - impl crate::factory::PluginFactory for ErrorOnInvokeFactory { - fn create( - &self, - config: &PluginConfig, - ) -> Result> { - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ErrorHandler); - Ok(crate::factory::PluginInstance { - plugin, - handlers: vec![("test_hook", handler)], - }) - } - } - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: flaky - kind: test/error_on_invoke - hooks: [test_hook] - mode: sequential - priority: 10 - on_error: disable -routes: - - tool: get_compensation - plugins: - - flaky: - config: - something: changed -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - let mgr = PluginManager::default(); - mgr.register_factory("test/error_on_invoke", Box::new(ErrorOnInvokeFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - assert!( - !mgr.get_plugin("flaky").unwrap().is_disabled(), - "should start enabled" - ); - - // Invoke a route that uses the override. The override's handler - // errors with `on_error: Disable`, so the executor calls disable() - // on the *override's* plugin_ref. Independent circuit breakers - // mean the base must stay enabled. - let payload: Box = Box::new(TestPayload { value: "t".into() }); - let _ = mgr - .invoke_by_name( - "test_hook", - payload, - make_meta("tool", "get_compensation", None, &[]), - None, - ) - .await; - - assert!( - !mgr.get_plugin("flaky").unwrap().is_disabled(), - "base must NOT be disabled when an override trips its own circuit breaker", - ); - } - - #[tokio::test] - async fn test_register_factory_then_load_config() { - let yaml = r#" -plugins: - - name: my_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 10 - -plugin_settings: - plugin_timeout: 45 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - assert_eq!(mgr.plugin_count(), 1); - assert!(mgr.has_hooks_for("test_hook")); - - let payload: Box = Box::new(TestPayload { value: "t".into() }); - // context_table = None (first invocation) - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - assert!(result.continue_processing); - } - - // -- End-to-end routing tests -- - - /// Helper to build meta extensions for routing tests. - fn make_meta( - entity_type: &str, - entity_name: &str, - scope: Option<&str>, - tags: &[&str], - ) -> Extensions { - let mut tag_set = std::collections::HashSet::new(); - for t in tags { - tag_set.insert(t.to_string()); - } - Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some(entity_type.into()), - entity_name: Some(entity_name.into()), - scope: scope.map(String::from), - tags: tag_set, - ..Default::default() - })), - ..Default::default() - } - } - - #[tokio::test] - async fn test_routing_full_flow_different_tools_different_plugins() { - // Setup: identity fires for all, apl_policy fires for pii tools, - // rate_limiter fires only for get_compensation route - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [identity] - pii: - plugins: [apl_policy] -plugins: - - name: identity - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 1 - - name: apl_policy - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 10 - - name: rate_limiter - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 5 -routes: - - tool: get_compensation - meta: - tags: [pii] - plugins: - - rate_limiter - - tool: send_email - plugins: - - rate_limiter -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // get_compensation: identity (all) + apl_policy (pii tag) + rate_limiter (route) - // apl_policy denies → overall denied - let p1: Box = Box::new(TestPayload { value: "t".into() }); - let (r1, _) = mgr - .invoke_by_name( - "test_hook", - p1, - make_meta("tool", "get_compensation", None, &[]), - None, - ) - .await; - assert!(!r1.continue_processing); // apl_policy (deny) fires due to pii tag - - // send_email: identity (all) + rate_limiter (route) — no pii tag - // both allow → overall allowed - let p2: Box = Box::new(TestPayload { value: "t".into() }); - let (r2, _) = mgr - .invoke_by_name( - "test_hook", - p2, - make_meta("tool", "send_email", None, &[]), - None, - ) - .await; - assert!(r2.continue_processing); // no deny plugin fires - } - - #[tokio::test] - async fn test_routing_disabled_fires_all_plugins() { - // Same plugins but routing disabled — all fire regardless of entity - let yaml = r#" -plugins: - - name: denier - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 10 - - name: allower - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 20 -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // Even with meta, routing disabled → all plugins fire → denier wins - let p: Box = Box::new(TestPayload { value: "t".into() }); - let (result, _) = mgr - .invoke_by_name( - "test_hook", - p, - make_meta("tool", "anything", None, &[]), - None, - ) - .await; - assert!(!result.continue_processing); // denier fires (all plugins active) - } - - #[tokio::test] - async fn test_routing_no_meta_fires_all_plugins() { - // Routing enabled but no meta on extensions → fallback to all - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allower] -plugins: - - name: allower - kind: test/allow - hooks: [test_hook] - mode: sequential - - name: denier - kind: test/deny - hooks: [test_hook] - mode: sequential -routes: - - tool: get_compensation - plugins: - - denier -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // No meta → all plugins fire (both allower and denier) - let p: Box = Box::new(TestPayload { value: "t".into() }); - let (result, _) = mgr - .invoke_by_name("test_hook", p, Extensions::default(), None) - .await; - // No meta → no route resolution → both plugins fire. The denier - // running is observable (the deny propagates to the result), so - // assert that — proves route filtering didn't accidentally hide it. - assert!( - !result.continue_processing, - "denier should run when no meta is provided (route filtering bypassed)", - ); - assert!( - result.violation.is_some(), - "deny should produce a violation" - ); - } - - #[tokio::test] - async fn test_routing_wildcard_catches_unmatched() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [identity] -plugins: - - name: identity - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 1 - - name: specific_plugin - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 10 - - name: fallback_plugin - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 10 -routes: - - tool: get_compensation - plugins: - - specific_plugin - - tool: "*" - plugins: - - fallback_plugin -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // get_compensation matches exact route → specific_plugin (deny) - let p1: Box = Box::new(TestPayload { value: "t".into() }); - let (r1, _) = mgr - .invoke_by_name( - "test_hook", - p1, - make_meta("tool", "get_compensation", None, &[]), - None, - ) - .await; - assert!(!r1.continue_processing); // specific_plugin denies - - // unknown_tool matches wildcard → fallback_plugin (allow) - let p2: Box = Box::new(TestPayload { value: "t".into() }); - let (r2, _) = mgr - .invoke_by_name( - "test_hook", - p2, - make_meta("tool", "unknown_tool", None, &[]), - None, - ) - .await; - assert!(r2.continue_processing); // fallback_plugin allows - } - - #[tokio::test] - async fn test_routing_host_tags_activate_policy_groups() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [identity] - urgent: - plugins: [denier] -plugins: - - name: identity - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 1 - - name: denier - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 10 -routes: - - tool: get_compensation -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // Without urgent tag → only identity fires → allowed - let p1: Box = Box::new(TestPayload { value: "t".into() }); - let (r1, _) = mgr - .invoke_by_name( - "test_hook", - p1, - make_meta("tool", "get_compensation", None, &[]), - None, - ) - .await; - assert!(r1.continue_processing); - - // Clear cache so new tags take effect - mgr.clear_routing_cache(); - - // With urgent tag from host → denier also fires → denied - let p2: Box = Box::new(TestPayload { value: "t".into() }); - let (r2, _) = mgr - .invoke_by_name( - "test_hook", - p2, - make_meta("tool", "get_compensation", None, &["urgent"]), - None, - ) - .await; - assert!(!r2.continue_processing); - } - - #[tokio::test] - async fn test_routing_works_with_typed_invoke() { - let yaml = r#" -plugin_settings: - routing_enabled: true -global: - policies: - all: - plugins: [allower] - pii: - plugins: [denier] -plugins: - - name: allower - kind: test/allow - hooks: [test_hook] - mode: sequential - priority: 1 - - name: denier - kind: test/deny - hooks: [test_hook] - mode: sequential - priority: 10 -routes: - - tool: get_compensation - meta: - tags: [pii] - - tool: send_email -"#; - let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mgr = PluginManager::default(); - mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); - mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); - mgr.load_config(cpex_config).unwrap(); - mgr.initialize().await.unwrap(); - - // context_table = None (first invocation) - - // Typed invoke for get_compensation — pii tag activates denier → denied - let (r1, _) = mgr - .invoke::( - TestPayload { value: "t".into() }, - make_meta("tool", "get_compensation", None, &[]), - None, - ) - .await; - assert!(!r1.continue_processing); - - // Typed invoke for send_email — no pii tag → only allower → allowed - let (r2, _) = mgr - .invoke::( - TestPayload { value: "t".into() }, - make_meta("tool", "send_email", None, &[]), - None, - ) - .await; - assert!(r2.continue_processing); - } - - // -- Executor tier validation tests -- - - /// Handler that modifies extensions via cow_copy — adds a label. - struct LabelAdderHandler; - - #[async_trait] - impl AnyHookHandler for LabelAdderHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - let mut ext = extensions.cow_copy(); - if let Some(ref mut sec) = ext.security { - sec.add_label("PLUGIN_ADDED"); - } - let mut result: PluginResult = PluginResult::allow(); - result.modified_extensions = Some(ext); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - /// Handler that tampers with an immutable extension slot. - struct ImmutableTampererHandler; - - #[async_trait] - impl AnyHookHandler for ImmutableTampererHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - let mut ext = extensions.cow_copy(); - // Tamper: replace the immutable request extension - ext.request = Some(std::sync::Arc::new(crate::extensions::RequestExtension { - request_id: Some("TAMPERED".into()), - ..Default::default() - })); - let mut result: PluginResult = PluginResult::allow(); - result.modified_extensions = Some(ext); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - #[tokio::test] - async fn test_executor_accepts_valid_label_addition() { - let mgr = PluginManager::default(); - let mut config = make_config("label-adder", 10, PluginMode::Sequential); - config.capabilities = ["append_labels".to_string(), "read_labels".to_string()].into(); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(LabelAdderHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - mgr.initialize().await.unwrap(); - - let mut security = crate::extensions::SecurityExtension::default(); - security.add_label("ORIGINAL"); - - let ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; - - assert!(result.continue_processing); - // The plugin added "PLUGIN_ADDED" — should be accepted (monotonic superset) - let modified = result.modified_extensions.as_ref().unwrap(); - let sec = modified.security.as_ref().unwrap(); - assert!(sec.has_label("ORIGINAL")); - assert!(sec.has_label("PLUGIN_ADDED")); - } - - #[tokio::test] - async fn test_executor_rejects_immutable_tampering() { - let mgr = PluginManager::default(); - let config = make_config("tamperer", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(ImmutableTampererHandler); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - mgr.initialize().await.unwrap(); - - let ext = Extensions { - request: Some(std::sync::Arc::new(crate::extensions::RequestExtension { - request_id: Some("original-req-id".into()), - ..Default::default() - })), - ..Default::default() - }; - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; - - assert!(result.continue_processing); - // Extensions should NOT be modified — the tampered immutable was rejected - // The result should have no modified_extensions (rejected by validation) - if let Some(ref modified) = result.modified_extensions { - // If modified extensions exist, the request should still be the original - assert_eq!( - modified.request.as_ref().unwrap().request_id.as_deref(), - Some("original-req-id"), - ); - } - } - - #[tokio::test] - async fn test_capability_filtering_hides_security_from_plugin() { - // Plugin has NO security capabilities — security should be None - - struct SecurityCheckerHandler { - saw_security: std::sync::Arc, - } - - #[async_trait] - impl AnyHookHandler for SecurityCheckerHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - // Check if security is visible - if extensions.security.is_some() { - self.saw_security - .store(true, std::sync::atomic::Ordering::SeqCst); - } - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let mgr = PluginManager::default(); - // No security capabilities declared - let config = make_config("no-sec-caps", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { - cfg: config.clone(), - }); - let handler: Arc = Arc::new(SecurityCheckerHandler { - saw_security: saw_security.clone(), - }); - mgr.register_raw::(plugin, config, handler) - .unwrap(); - mgr.initialize().await.unwrap(); - - let mut security = crate::extensions::SecurityExtension::default(); - security.add_label("SECRET"); - security.subject = Some(crate::extensions::security::SubjectExtension { - id: Some("alice".into()), - ..Default::default() - }); - - let ext = Extensions { - security: Some(Arc::new(security)), - ..Default::default() - }; - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; - - assert!(result.continue_processing); - // Plugin should NOT have seen security — no capabilities declared - // Security is still there but labels and subject are empty/none - // (filter_extensions strips gated fields) - // The saw_security flag checks if the security Option itself was Some - // With filter_extensions, security IS Some but with empty labels and no subject - // So saw_security will be true, but the content is filtered - } - - /// Plugin that genuinely awaits inside its handler. Increments a - /// shared counter after the await resolves so the test can verify - /// the handler ran end-to-end and observed its async point. - struct AsyncCounterPlugin { - cfg: PluginConfig, - counter: Arc, - } - - #[async_trait] - impl Plugin for AsyncCounterPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - - impl HookHandler for AsyncCounterPlugin { - async fn handle( - &self, - _payload: &TestPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - tokio::task::yield_now().await; - tokio::time::sleep(std::time::Duration::from_micros(1)).await; - self.counter - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - PluginResult::allow() - } - } - - /// Verifies that a handler that genuinely `.await`s gets driven - /// to completion before its result is observed. - #[tokio::test] - async fn test_async_handler_registers_and_invokes() { - let mgr = PluginManager::default(); - let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); - let cfg = make_config("async-counter", 10, PluginMode::Sequential); - let plugin = Arc::new(AsyncCounterPlugin { - cfg: cfg.clone(), - counter: counter.clone(), - }); - - // Same call path as sync plugins — no `register_async_handler`. - mgr.register_handler::(plugin, cfg).unwrap(); - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert!(result.violation.is_none()); - // Counter increments only after the await resolves, so a non-zero - // value proves the future was actually driven to completion. - assert_eq!( - counter.load(std::sync::atomic::Ordering::SeqCst), - 1, - "async handler should have run once", - ); - } - - /// A handler with no `.await` (AllowPlugin) and a handler that - /// genuinely awaits (AsyncCounterPlugin) co-register on the same - /// hook via the same `register_handler` call. Both run in priority - /// order. - #[tokio::test] - async fn test_mixed_sync_and_async_handlers_in_same_hook() { - let mgr = PluginManager::default(); - let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); - - let sync_cfg = make_config("sync-allow", 10, PluginMode::Sequential); - let sync_plugin = Arc::new(AllowPlugin { - cfg: sync_cfg.clone(), - }); - mgr.register_handler::(sync_plugin, sync_cfg) - .unwrap(); - - let async_cfg = make_config("async-counter", 20, PluginMode::Sequential); - let async_plugin = Arc::new(AsyncCounterPlugin { - cfg: async_cfg.clone(), - counter: counter.clone(), - }); - mgr.register_handler::(async_plugin, async_cfg) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(result.continue_processing); - assert_eq!( - counter.load(std::sync::atomic::Ordering::SeqCst), - 1, - "awaiting plugin should have run alongside the non-awaiting plugin", - ); - } -} diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs deleted file mode 100644 index 21282dc2..00000000 --- a/crates/cpex-core/src/plugin.rs +++ /dev/null @@ -1,533 +0,0 @@ -// Location: ./crates/cpex-core/src/plugin.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Plugin trait and supporting types. -// -// Defines the core Plugin trait that all plugin implementations satisfy — -// native Rust, WASM hosts, Python bridge hosts, and dlopen'd shared -// libraries. Also defines PluginConfig (YAML-declared plugin settings), -// PluginMode (5-phase execution modes), and OnError (failure behavior). -// -// The Plugin trait handles lifecycle only (initialize, shutdown, config). -// Hook-specific logic is defined by handler traits generated by the -// define_hook! macro (see hooks/macros.rs). A plugin implements Plugin -// for lifecycle + one or more handler traits for the hooks it handles. -// -// The manager wraps each plugin in a PluginRef with an authoritative -// config from the config loader — the plugin's own config() is for -// the plugin's reading only, never used by the executor for scheduling. - -use std::collections::HashSet; -use std::fmt; - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -use crate::error::PluginError; - -/// Core plugin interface — lifecycle management only. -/// -/// Every plugin in the CPEX framework — regardless of language or -/// deployment model — implements this trait. It covers lifecycle -/// (initialize, shutdown) and identity (config). Hook-specific logic -/// is defined separately by handler traits generated by `define_hook!`. -/// -/// # Lifecycle -/// -/// 1. `initialize()` — called once after loading, before any hooks fire. -/// 2. Hook handlers — called on each hook invocation (defined by handler traits). -/// 3. `shutdown()` — called once during graceful teardown. -/// -/// # Hook Handlers -/// -/// A plugin implements one or more handler traits alongside Plugin: -/// -/// ```rust,ignore -/// impl Plugin for MyPlugin { -/// fn config(&self) -> &PluginConfig { &self.config } -/// async fn initialize(&self) -> Result<(), Box> { Ok(()) } -/// async fn shutdown(&self) -> Result<(), Box> { Ok(()) } -/// } -/// -/// impl CmfHookHandler for MyPlugin { -/// fn cmf_hook(&self, payload: MessagePayload, ext: &Extensions, ctx: &PluginContext) -> PluginResult { -/// PluginResult::allow() -/// } -/// } -/// ``` -/// -/// # Trust Model -/// -/// The manager wraps each plugin in a `PluginRef` with an authoritative -/// config from the config loader. The executor reads scheduling decisions -/// (mode, priority, hooks, capabilities) from the `PluginRef` — never -/// from `plugin.config()`. The plugin's own `config()` is available for -/// the plugin's reading during hook execution. -/// -/// # Implementors -/// -/// - Native Rust plugins (implement directly) -/// - `cpex-hosts::wasm` (bridges to WASM guest via wasmtime) -/// - `cpex-hosts::python` (bridges to Python plugin classes via PyO3) -/// - `cpex-hosts::native` (bridges to dlopen'd shared libraries) -#[async_trait] -pub trait Plugin: Send + Sync { - /// Returns the plugin's configuration. - /// - /// Available for the plugin's own reading during hook execution. - /// The manager/executor never reads this — they use the authoritative - /// config from `PluginRef.trusted_config()`. - fn config(&self) -> &PluginConfig; - - /// One-time initialization after loading. - /// - /// Called before any hook invocations. Use this to establish - /// connections, load resources, or validate configuration. - /// Default implementation does nothing. - async fn initialize(&self) -> Result<(), Box> { - Ok(()) - } - - /// Graceful shutdown. - /// - /// Called once during teardown. Use this to flush buffers, close - /// connections, or release resources. - /// Default implementation does nothing. - async fn shutdown(&self) -> Result<(), Box> { - Ok(()) - } -} - -/// Declared plugin configuration from the unified YAML config. -/// -/// Controls how the framework loads, schedules, and gates the plugin. -/// Corresponds to a single entry in the `plugins:` list in config YAML. -/// -/// The manager holds the authoritative copy in `PluginRef.trusted_config`. -/// The plugin receives its own copy for reading via `Plugin::config()`. -/// -/// # Examples -/// -/// ```yaml -/// plugins: -/// - name: apl-policy -/// kind: builtin -/// hooks: [tool_pre_invoke, tool_post_invoke] -/// mode: sequential -/// priority: 10 -/// on_error: fail -/// capabilities: [read_security, append_labels] -/// config: -/// policy_file: apl/demo/hr_policy.yaml -/// ``` -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginConfig { - /// Unique plugin name. - pub name: String, - - /// Plugin kind — determines how the framework loads it. - /// - /// - `"builtin"` — compiled into the runtime - /// - `"native://path/to/lib.so"` — dlopen'd shared library - /// - `"wasm://path/to/plugin.wasm"` — wasmtime sandbox - /// - `"python://module.path.ClassName"` — PyO3 bridge - /// - `"external"` — MCP/gRPC/Unix socket transport - pub kind: String, - - /// Human-readable description. - #[serde(default)] - pub description: Option, - - /// Plugin author or team. - #[serde(default)] - pub author: Option, - - /// Semantic version string. - #[serde(default)] - pub version: Option, - - /// Hook names this plugin handles. - #[serde(default)] - pub hooks: Vec, - - /// Execution mode — determines scheduling behavior and authority. - #[serde(default)] - pub mode: PluginMode, - - /// Execution priority — lower numbers execute first within each mode. - #[serde(default = "default_priority")] - pub priority: i32, - - /// Error handling behavior when the plugin fails. - #[serde(default)] - pub on_error: OnError, - - /// Declared capabilities for extension visibility gating. - /// - /// Controls which extensions the plugin can see and modify. - /// Extensions not covered by declared capabilities appear as - /// `None` in the filtered view. - #[serde(default)] - pub capabilities: HashSet, - - /// Tags for categorization and searchability. - #[serde(default)] - pub tags: Vec, - - /// Legacy conditions for when the plugin should execute. - /// - /// Each condition narrows the plugin's scope by server, tenant, - /// tool name, prompt name, etc. If any condition in the list - /// matches, the plugin runs. If the list is empty (default), - /// the plugin runs unconditionally. - /// - /// **Backward compatibility:** Conditions are the legacy mechanism - /// for scoping plugins. When the host uses the unified routing - /// system (`routes:` in config YAML), routing rules handle scope - /// matching and conditions on the plugin are ignored. The two - /// mechanisms should not be used together on the same plugin. - #[serde(default)] - pub conditions: Vec, - - /// Plugin-specific configuration (opaque to the framework). - #[serde(default)] - pub config: Option, -} - -impl PluginConfig { - /// Whether this plugin's `conditions` allow it to fire for the given - /// request `Extensions`. Used in legacy mode (`routing_enabled: false`) - /// to filter which plugins run per request — mirrors the Python - /// implementation's per-plugin condition filtering. - /// - /// Semantics: - /// - Empty `conditions` Vec → fire always (no restriction). - /// - Non-empty → fire if ANY condition matches (OR across the list, - /// AND within each individual condition). - /// - /// Field-source mapping (see project memory `project_conditions_field_mapping`): - /// - `server_ids` ← `extensions.mcp.{tool|resource|prompt}.server_id` - /// - `tenant_ids` ← `extensions.security.subject.claims["tenant"]` - /// - `tools|prompts|resources` ← `extensions.meta.entity_name` (when matching `entity_type`) - /// - `agents` ← `extensions.agent.agent_id` - /// - `user_patterns` ← `extensions.security.subject.id` (glob match) - /// - `content_types` ← `extensions.mcp.resource.mime_type` - pub fn passes_conditions(&self, extensions: &crate::hooks::payload::Extensions) -> bool { - if self.conditions.is_empty() { - return true; - } - - // Source values once from the extensions tree. - let server_id = extensions.mcp.as_ref().and_then(|m| { - m.tool - .as_ref() - .and_then(|t| t.server_id.as_deref()) - .or_else(|| m.resource.as_ref().and_then(|r| r.server_id.as_deref())) - .or_else(|| m.prompt.as_ref().and_then(|p| p.server_id.as_deref())) - }); - let tenant_id = extensions - .security - .as_ref() - .and_then(|s| s.subject.as_ref()) - .and_then(|sub| sub.claims.get("tenant")) - .map(|s| s.as_str()); - let entity_name = extensions - .meta - .as_ref() - .and_then(|m| m.entity_name.as_deref()); - let entity_type = extensions - .meta - .as_ref() - .and_then(|m| m.entity_type.as_deref()); - let (tool, prompt, resource) = match entity_type { - Some("tool") => (entity_name, None, None), - Some("prompt") => (None, entity_name, None), - Some("resource") => (None, None, entity_name), - _ => (None, None, None), - }; - let agent = extensions - .agent - .as_ref() - .and_then(|a| a.agent_id.as_deref()); - let user = extensions - .security - .as_ref() - .and_then(|s| s.subject.as_ref()) - .and_then(|sub| sub.id.as_deref()); - let content_type = extensions - .mcp - .as_ref() - .and_then(|m| m.resource.as_ref()) - .and_then(|r| r.mime_type.as_deref()); - - let ctx = MatchContext { - server_id, - tenant_id, - tool, - prompt, - resource, - agent, - user, - content_type, - }; - self.conditions.iter().any(|c| c.matches(&ctx)) - } -} - -fn default_priority() -> i32 { - 100 -} - -/// Condition for when a plugin should execute. -/// -/// Narrows plugin scope to specific servers, tenants, tools, prompts, -/// resources, or agents. All fields are optional — only specified -/// fields participate in matching. Within a field, any match suffices -/// (OR semantics). Across fields, all must match (AND semantics). -/// -/// This is the legacy scoping mechanism. The unified routing system -/// (`routes:` in config) supersedes this — when routes are used, -/// conditions are ignored. -/// -/// Mirrors Python's `PluginCondition` in `cpex/framework/models.py`. -/// -/// # Examples -/// -/// ``` -/// use cpex_core::plugin::PluginCondition; -/// -/// // Only run for specific tools on specific servers -/// let cond = PluginCondition { -/// server_ids: Some(vec!["server-1".into(), "server-2".into()].into_iter().collect()), -/// tools: Some(vec!["get_compensation".into()].into_iter().collect()), -/// ..Default::default() -/// }; -/// assert!(cond.server_ids.as_ref().unwrap().contains("server-1")); -/// assert!(cond.tools.as_ref().unwrap().contains("get_compensation")); -/// ``` -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginCondition { - /// Set of server IDs — plugin runs only on these servers. - #[serde(default)] - pub server_ids: Option>, - - /// Set of tenant IDs — plugin runs only for these tenants. - #[serde(default)] - pub tenant_ids: Option>, - - /// Set of tool names — plugin runs only for these tools. - #[serde(default)] - pub tools: Option>, - - /// Set of prompt names — plugin runs only for these prompts. - #[serde(default)] - pub prompts: Option>, - - /// Set of resource identifiers — plugin runs only for these resources. - #[serde(default)] - pub resources: Option>, - - /// Set of agent identifiers — plugin runs only for these agents. - #[serde(default)] - pub agents: Option>, - - /// User patterns (glob or regex) — plugin runs only for matching users. - #[serde(default)] - pub user_patterns: Option>, - - /// Content types — plugin runs only for these content types. - #[serde(default)] - pub content_types: Option>, -} - -/// Bundle of optional context values used to evaluate a `PluginCondition`. -/// -/// Each field corresponds to one of the condition's gates. `None` means -/// "no value sourced from the extensions tree"; the condition then -/// rejects when the corresponding `Some(set)` is set on the condition -/// (i.e., the gate was specified but couldn't be evaluated). -/// -/// Replaces an 8-arg `matches(...)` call where every arg was -/// `Option<&str>` and could be misordered silently. -#[derive(Debug, Default, Clone, Copy)] -pub struct MatchContext<'a> { - pub server_id: Option<&'a str>, - pub tenant_id: Option<&'a str>, - pub tool: Option<&'a str>, - pub prompt: Option<&'a str>, - pub resource: Option<&'a str>, - pub agent: Option<&'a str>, - pub user: Option<&'a str>, - pub content_type: Option<&'a str>, -} - -impl PluginCondition { - /// Whether this condition matches the given context. - /// - /// A field that is `None` is treated as "any" (no restriction). - /// A `Some(set)` field matches if the given value is in the set - /// (exact match for ID-shaped fields; glob match via `wildmatch` - /// for `user_patterns`). - /// All specified fields must match — AND semantics within one condition. - pub fn matches(&self, ctx: &MatchContext<'_>) -> bool { - let MatchContext { - server_id, - tenant_id, - tool, - prompt, - resource, - agent, - user, - content_type, - } = *ctx; - let check_set = |field: &Option>, value: Option<&str>| -> bool { - match field { - None => true, // not specified — matches anything - Some(set) => match value { - Some(v) => set.contains(v), - None => false, // field required but no value provided - }, - } - }; - - // user_patterns: list of globs. Match if any pattern matches the user. - let check_patterns = |field: &Option>, value: Option<&str>| -> bool { - match field { - None => true, - Some(patterns) => match value { - Some(v) => patterns - .iter() - .any(|p| wildmatch::WildMatch::new(p).matches(v)), - None => false, - }, - } - }; - - // content_types: list of exact strings. - let check_list = |field: &Option>, value: Option<&str>| -> bool { - match field { - None => true, - Some(list) => match value { - Some(v) => list.iter().any(|s| s == v), - None => false, - }, - } - }; - - check_set(&self.server_ids, server_id) - && check_set(&self.tenant_ids, tenant_id) - && check_set(&self.tools, tool) - && check_set(&self.prompts, prompt) - && check_set(&self.resources, resource) - && check_set(&self.agents, agent) - && check_patterns(&self.user_patterns, user) - && check_list(&self.content_types, content_type) - } -} - -/// Execution mode — determines a plugin's scheduling behavior and authority. -/// -/// The 5-phase model defines both what a plugin *can do* (block, modify) -/// and *how it runs* (serial, parallel, background). Scheduling is derived -/// from mode; plugin authors don't control it directly. -/// -/// # Execution Order -/// -/// ```text -/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET -/// ``` -/// -/// # Mode Capabilities -/// -/// | Mode | Can Block? | Can Modify? | Execution | -/// |----------------|------------|-------------|-----------------| -/// | Sequential | Yes | Yes | Serial, chained | -/// | Transform | No | Yes | Serial, chained | -/// | Audit | No | No | Serial | -/// | Concurrent | Yes | No | Parallel | -/// | FireAndForget | No | No | Background | -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum PluginMode { - /// Policy enforcement + transformation. Serial, chained. Can block and modify. - #[default] - Sequential, - - /// Data shaping (PII redaction, normalization). Serial, chained. Can modify, cannot block. - Transform, - - /// Observation and logging. Serial, read-only. Cannot block or modify. - Audit, - - /// Independent policy gates. Parallel, fail-fast. Can block, cannot modify. - Concurrent, - - /// Telemetry and async side effects. Background tasks. Cannot block or modify. - FireAndForget, - - /// Plugin is disabled — skipped during execution. - Disabled, -} - -impl PluginMode { - /// Whether this mode allows the plugin to block the pipeline. - pub fn can_block(&self) -> bool { - matches!(self, Self::Sequential | Self::Concurrent) - } - - /// Whether this mode allows the plugin to modify the payload. - pub fn can_modify(&self) -> bool { - matches!(self, Self::Sequential | Self::Transform) - } - - /// Whether the framework waits for this plugin to complete. - pub fn is_awaited(&self) -> bool { - !matches!(self, Self::FireAndForget | Self::Disabled) - } -} - -impl fmt::Display for PluginMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Sequential => write!(f, "sequential"), - Self::Transform => write!(f, "transform"), - Self::Audit => write!(f, "audit"), - Self::Concurrent => write!(f, "concurrent"), - Self::FireAndForget => write!(f, "fire_and_forget"), - Self::Disabled => write!(f, "disabled"), - } - } -} - -/// Error handling behavior when a plugin fails. -/// -/// Independent of [`PluginMode`] — any mode can use any error behavior. -/// Controls whether plugin failures halt the pipeline, are logged and -/// skipped, or cause the plugin to be auto-disabled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum OnError { - /// Pipeline halts and error propagates. Fail-safe enforcement. - #[default] - Fail, - - /// Error logged, pipeline continues. For non-critical plugins. - Ignore, - - /// Plugin auto-disabled after error. Prevents repeated failures. - Disable, -} - -impl fmt::Display for OnError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Fail => write!(f, "fail"), - Self::Ignore => write!(f, "ignore"), - Self::Disable => write!(f, "disable"), - } - } -} diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs deleted file mode 100644 index 63eb2cf4..00000000 --- a/crates/cpex-core/src/registry.rs +++ /dev/null @@ -1,728 +0,0 @@ -// Location: ./crates/cpex-core/src/registry.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Plugin and hook registries. -// -// PluginRef wraps a plugin implementation with the manager's -// authoritative config. The config comes from the config loader, -// NOT from the plugin — the plugin never provides its own config -// to the manager. This prevents a plugin from tampering with its -// own priority, mode, or capabilities. -// -// Trust flows one direction: -// config loader → manager → PluginRef → executor -// The plugin is just a recipient, not a source. -// -// The registry supports two registration paths: -// -// 1. **Typed** (`register::()`) — for Rust plugins implementing -// a handler trait generated by define_hook!. The handler is stored -// type-erased alongside the PluginRef. At dispatch time, the typed -// path (`invoke::()`) downcasts back; the dynamic path -// (`invoke_by_name()`) calls through the type-erased interface. -// -// 2. **Name-based** (`register_for_names::()`) — same handler -// registered under multiple hook names (the CMF pattern). -// -// Mirrors the Python framework's PluginRef and PluginInstanceRegistry -// in cpex/framework/base.py and cpex/framework/registry.py. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; - -use std::sync::Arc; -use uuid::Uuid; - -use crate::context::PluginContext; -use crate::hooks::payload::{Extensions, PluginPayload}; -use crate::hooks::trait_def::HookTypeDef; -use crate::hooks::HookType; -use crate::plugin::{Plugin, PluginConfig, PluginMode}; - -/// Manager-owned wrapper that pairs a plugin with its authoritative config. -/// -/// The `trusted_config` comes from the config loader / manager — never -/// from the plugin itself. The executor reads all scheduling decisions -/// (mode, priority, hooks, capabilities, on_error) from this config. -/// -/// The plugin receives a copy of its config at construction time so it -/// can read its own settings during hook execution. But the manager/executor -/// never reads config back from the plugin. -/// -/// Trust flow: -/// ```text -/// config loader → manager → PluginRef.trusted_config → executor -/// ↘ plugin (receives a copy, cannot influence scheduling) -/// ``` -#[derive(Clone)] -pub struct PluginRef { - /// The plugin implementation. - plugin: Arc, - - /// Authoritative config from the config loader. - /// The executor uses this for all scheduling and capability decisions. - trusted_config: PluginConfig, - - /// Unique identifier assigned by the registry. - /// Stored as `Uuid` (16 bytes, `Copy`) rather than a 36-char `String` - /// to avoid heap allocation per registered plugin and to give - /// downstream `HashMap` keys fixed-size hashing. - id: Uuid, - - /// Runtime circuit breaker — set to true when `on_error: Disable` - /// triggers. Once set, `mode()` returns `Disabled` and the plugin - /// is skipped by `group_by_mode()` on all subsequent invocations. - /// Uses `Arc` so clones (in HookEntry) share the same flag. - disabled: Arc, -} - -impl PluginRef { - /// Create a new PluginRef with an independently-sourced config. - /// - /// The `trusted_config` must come from the config loader or manager, - /// NOT from `plugin.config()`. The plugin may hold its own copy - /// for reading during execute(), but the manager never consults it. - pub fn new(plugin: Arc, trusted_config: PluginConfig) -> Self { - Self { - plugin, - trusted_config, - id: Uuid::new_v4(), - disabled: Arc::new(AtomicBool::new(false)), - } - } - - /// The authoritative config used by the executor for all decisions. - pub fn trusted_config(&self) -> &PluginConfig { - &self.trusted_config - } - - /// The plugin implementation (for calling initialize/shutdown). - pub fn plugin(&self) -> &Arc { - &self.plugin - } - - /// Unique identifier assigned at registration. - /// Returned by value — `Uuid` is `Copy` (16 bytes). - pub fn id(&self) -> Uuid { - self.id - } - - /// Convenience: plugin name from the trusted config. - pub fn name(&self) -> &str { - &self.trusted_config.name - } - - /// Effective mode — returns `Disabled` if the runtime circuit breaker - /// has tripped, otherwise returns the configured mode. - /// - /// `Acquire` on the load pairs with `Release` on the disable() store - /// so weak-memory-ordering hardware (ARM64) propagates the disable - /// promptly across threads. - pub fn mode(&self) -> PluginMode { - if self.disabled.load(Ordering::Acquire) { - PluginMode::Disabled - } else { - self.trusted_config.mode - } - } - - /// Runtime-disable this plugin (one-way circuit breaker). - /// - /// Called by the executor when a plugin errors with `on_error: Disable`. - /// All clones of this PluginRef (in HookEntry, etc.) share the same - /// `AtomicBool`, so the disable is visible across the system. - /// - /// `Release` ordering establishes a happens-before with `Acquire` - /// loads in `is_disabled()` and `mode()` — required for correctness - /// on weak-memory hardware (ARM64) where `Relaxed` allows the new - /// value to remain unobserved by other threads for an unbounded window. - pub fn disable(&self) { - self.disabled.store(true, Ordering::Release); - } - - /// Whether this plugin has been runtime-disabled. - /// `Acquire` pairs with the `Release` in `disable()` (see `mode()`). - pub fn is_disabled(&self) -> bool { - self.disabled.load(Ordering::Acquire) - } - - /// Convenience: plugin priority from the trusted config. - pub fn priority(&self) -> i32 { - self.trusted_config.priority - } -} - -/// Type-erased interface for calling a hook handler. -/// -/// The executor uses this to dispatch hooks without knowing the -/// concrete handler trait at compile time. Each handler wraps a -/// plugin that implements a specific handler trait (e.g., -/// `CmfHookHandler`) and translates between type-erased payloads -/// and the typed handler method. -/// -/// The executor dispatches through this trait for all five phases. -/// The handler receives a borrowed payload — the framework retains -/// ownership. Plugins clone only when modifying. -/// -/// `invoke` is async so that plugins can perform I/O (HTTP calls, -/// Redis, vault lookups) without blocking the tokio runtime, and -/// so that `tokio::time::timeout` can actually observe and cancel -/// long-running handlers. -#[async_trait::async_trait] -pub trait AnyHookHandler: Send + Sync { - /// Call the handler with a borrowed payload. - /// - /// Returns an `ErasedResultFields` (see executor module) wrapped - /// as `Box`. If the handler modified the payload, the - /// modified copy is in `ErasedResultFields.modified_payload`. - async fn invoke( - &self, - payload: &dyn PluginPayload, - extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, Box>; - - /// The hook type name this handler was registered for. - fn hook_type_name(&self) -> &'static str; -} - -/// A registered hook handler paired with its PluginRef. -/// -/// The executor uses `plugin_ref` for scheduling decisions (mode, -/// priority, capabilities) and `handler` for actual dispatch. -/// -/// `plugin_ref` is `Arc` so cloning a `HookEntry` is two -/// reference-count bumps rather than a deep clone of the embedded -/// `PluginConfig`. `group_by_mode` (called once per invoke) clones N -/// entries — keeping that cheap matters at high request rates. -#[derive(Clone)] -pub struct HookEntry { - /// The plugin wrapper with authoritative config. - pub plugin_ref: Arc, - - /// The type-erased handler for this specific hook. - pub handler: Arc, -} - -/// Manages registered plugin instances and hook handler mappings. -/// -/// Stores `PluginRef` wrappers by name and `HookEntry` (PluginRef + -/// handler) by hook name. The executor reads scheduling decisions -/// from `PluginRef.trusted_config` and dispatches through the -/// type-erased handler. -/// -/// Supports two registration patterns: -/// -/// - `register::()` — typed registration for a single hook name -/// (derived from `H::NAME`). -/// - `register_for_names::()` — typed registration for multiple -/// hook names (the CMF pattern where one handler covers -/// `cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). -/// -/// `Clone` is cheap-ish: the HashMaps duplicate, but their values are all -/// `Arc`-counted (`Arc`, `Arc`), so the -/// inner data is shared. Used by `PluginManager`'s `ArcSwap` snapshot -/// pattern, where every mutating method clones the registry, mutates the -/// clone, and atomically swaps in a new snapshot. -#[derive(Clone)] -pub struct PluginRegistry { - /// Plugins keyed by name (for lookup and lifecycle). Wrapped in `Arc` - /// so the same instance is shared with every `HookEntry` in - /// `hook_index` — registering a plugin allocates one `PluginRef`, - /// not one per hook. - plugins: HashMap>, - - /// Hook name → list of HookEntries, sorted by priority. - hook_index: HashMap>, -} - -impl PluginRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { - plugins: HashMap::new(), - hook_index: HashMap::new(), - } - } - - /// Register a typed hook handler for its primary hook name. - /// - /// The handler is registered under `H::NAME`. The `config` must - /// come from the config loader — not from the plugin. The plugin - /// must implement the handler trait generated by `define_hook!`. - /// - /// # Type Parameters - /// - /// - `H` — the hook type (implements `HookTypeDef`). - /// - /// # Arguments - /// - /// - `plugin` — the plugin implementation (must also implement the handler trait). - /// - `config` — authoritative config from the config loader. - /// - `handler` — type-erased handler wrapping the plugin's handler trait impl. - pub fn register( - &mut self, - plugin: Arc, - config: PluginConfig, - handler: Arc, - ) -> Result<(), String> { - self.register_for_names_inner(plugin, config, handler, &[H::NAME]) - } - - /// Register a typed hook handler for multiple hook names. - /// - /// This is the CMF pattern — one handler trait impl covers multiple - /// hook names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). - /// - /// # Arguments - /// - /// - `plugin` — the plugin implementation. - /// - `config` — authoritative config from the config loader. - /// - `handler` — type-erased handler. - /// - `names` — hook names to register under. - pub fn register_for_names( - &mut self, - plugin: Arc, - config: PluginConfig, - handler: Arc, - names: &[&str], - ) -> Result<(), String> { - self.register_for_names_inner(plugin, config, handler, names) - } - - /// Register a plugin with a handler for multiple hook names. - /// - /// Like `register_for_names` but without requiring a `HookTypeDef` - /// type parameter. Used by the config-driven factory path where - /// the hook type is not known at compile time — the factory - /// provides the handler directly. - pub fn register_for_names_with_handler( - &mut self, - plugin: Arc, - config: PluginConfig, - handler: Arc, - names: &[&str], - ) -> Result<(), String> { - self.register_for_names_inner(plugin, config, handler, names) - } - - /// Register a plugin with multiple handlers, each for a specific hook. - /// - /// Used when a plugin implements multiple hook types with different - /// payloads (e.g., `ToolPreInvoke` and `ToolPostInvoke`). Each - /// handler is registered under its paired hook name. - /// - /// The plugin is registered once in the name index. Each handler - /// gets its own `HookEntry` in the hook index under the specified name. - pub fn register_multi_handler( - &mut self, - plugin: Arc, - config: PluginConfig, - handlers: Vec<(&str, Arc)>, - ) -> Result<(), String> { - let name = config.name.clone(); - - if self.plugins.contains_key(&name) { - return Err(format!("plugin '{}' is already registered", name)); - } - - let plugin_ref = Arc::new(PluginRef::new(plugin, config)); - - for (hook_name, handler) in &handlers { - let hook_type = HookType::new(*hook_name); - let entry = HookEntry { - plugin_ref: Arc::clone(&plugin_ref), - handler: Arc::clone(handler), - }; - self.hook_index.entry(hook_type).or_default().push(entry); - } - - // Sort each affected hook's entry list by trusted priority - for (hook_name, _) in &handlers { - let hook_type = HookType::new(*hook_name); - if let Some(entries) = self.hook_index.get_mut(&hook_type) { - entries.sort_by_key(|e| e.plugin_ref.priority()); - } - } - - self.plugins.insert(name, plugin_ref); - Ok(()) - } - - /// Internal: register handler under one or more hook names. - fn register_for_names_inner( - &mut self, - plugin: Arc, - config: PluginConfig, - handler: Arc, - names: &[&str], - ) -> Result<(), String> { - let name = config.name.clone(); - - if self.plugins.contains_key(&name) { - return Err(format!("plugin '{}' is already registered", name)); - } - - let plugin_ref = Arc::new(PluginRef::new(plugin, config)); - - // Add to hook index for each specified hook name - for hook_name in names { - let hook_type = HookType::new(*hook_name); - let entry = HookEntry { - plugin_ref: Arc::clone(&plugin_ref), - handler: Arc::clone(&handler), - }; - self.hook_index.entry(hook_type).or_default().push(entry); - } - - // Sort each affected hook's entry list by trusted priority - for hook_name in names { - let hook_type = HookType::new(*hook_name); - if let Some(entries) = self.hook_index.get_mut(&hook_type) { - entries.sort_by_key(|e| e.plugin_ref.priority()); - } - } - - self.plugins.insert(name, plugin_ref); - Ok(()) - } - - /// Unregister a plugin by name. - /// - /// Removes the PluginRef from the name index and all HookEntries - /// from the hook index. Returns the (Arc-wrapped) PluginRef if found. - pub fn unregister(&mut self, name: &str) -> Option> { - let plugin_ref = self.plugins.remove(name)?; - - for entries in self.hook_index.values_mut() { - entries.retain(|e| e.plugin_ref.name() != name); - } - - self.hook_index.retain(|_, entries| !entries.is_empty()); - - Some(plugin_ref) - } - - /// Look up a PluginRef by name. Returns an `Arc` clone so callers - /// don't hold borrows on internal storage — works with snapshot-based - /// dispatch where the registry may sit behind a transient guard. - pub fn get(&self, name: &str) -> Option> { - self.plugins.get(name).map(Arc::clone) - } - - /// Returns all HookEntries for a given hook name, sorted by priority. - /// - /// Returns an empty slice if no plugins are registered for the hook. - pub fn entries_for_hook(&self, hook_type: &HookType) -> &[HookEntry] { - self.hook_index - .get(hook_type) - .map(|v| v.as_slice()) - .unwrap_or(&[]) - } - - /// Whether any plugins are registered for the given hook name. - pub fn has_hooks_for(&self, hook_type: &HookType) -> bool { - self.hook_index - .get(hook_type) - .map(|v| !v.is_empty()) - .unwrap_or(false) - } - - /// Total number of registered plugins. - pub fn plugin_count(&self) -> usize { - self.plugins.len() - } - - /// All registered plugin names. Returns owned `String`s so callers - /// don't hold borrows on internal storage — works with snapshot-based - /// dispatch where the registry may sit behind a transient guard. - pub fn plugin_names(&self) -> Vec { - self.plugins.keys().cloned().collect() - } - - /// Returns every (hook_name, HookEntry) pair where the entry's plugin - /// matches the given name. Used by external orchestrators that need - /// to build pre-resolved dispatch lineups for a single plugin across - /// every hook it registered to (e.g. apl-cpex deciding which entry - /// handles step-style invocations vs field-style invocations for the - /// same plugin). Owned tuples — no borrows held on the registry. - pub fn entries_for_plugin(&self, plugin_name: &str) -> Vec<(String, HookEntry)> { - let mut out = Vec::new(); - for (hook_type, entries) in &self.hook_index { - for entry in entries { - if entry.plugin_ref.name() == plugin_name { - out.push((hook_type.as_str().to_string(), entry.clone())); - } - } - } - out - } -} - -impl Default for PluginRegistry { - fn default() -> Self { - Self::new() - } -} - -/// Groups a list of HookEntries by their execution mode. -/// -/// Reads mode from `plugin_ref.trusted_config` — never from the plugin. -/// Returns a tuple of five vectors in execution order: -/// (sequential, transform, audit, concurrent, fire_and_forget). -/// Disabled plugins are excluded. -pub type GroupedHookEntries = ( - Vec, - Vec, - Vec, - Vec, - Vec, -); - -pub fn group_by_mode(entries: &[HookEntry]) -> GroupedHookEntries { - let mut sequential = Vec::new(); - let mut transform = Vec::new(); - let mut audit = Vec::new(); - let mut concurrent = Vec::new(); - let mut fire_and_forget = Vec::new(); - - for entry in entries { - match entry.plugin_ref.mode() { - PluginMode::Sequential => sequential.push(entry.clone()), - PluginMode::Transform => transform.push(entry.clone()), - PluginMode::Audit => audit.push(entry.clone()), - PluginMode::Concurrent => concurrent.push(entry.clone()), - PluginMode::FireAndForget => fire_and_forget.push(entry.clone()), - PluginMode::Disabled => {}, // skip - } - } - - (sequential, transform, audit, concurrent, fire_and_forget) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::PluginError; - use crate::hooks::payload::PluginPayload; - use crate::hooks::PluginResult; - use async_trait::async_trait; - - // -- Test payload and hook type -- - - #[derive(Debug, Clone)] - #[allow(dead_code)] // test fixture — typed shape is the point, not field reads - struct TestPayload { - value: String, - } - crate::impl_plugin_payload!(TestPayload); - - // -- Test handler (type-erased wrapper) -- - - /// A simple AnyHookHandler that wraps a function for testing. - struct TestHandler; - - #[async_trait] - impl AnyHookHandler for TestHandler { - async fn invoke( - &self, - _payload: &dyn PluginPayload, - _extensions: &Extensions, - _ctx: &mut PluginContext, - ) -> Result, Box> { - let result: PluginResult = PluginResult::allow(); - Ok(crate::executor::erase_result(result)) - } - - fn hook_type_name(&self) -> &'static str { - "test_hook" - } - } - - // -- Test plugin -- - - struct TestPlugin { - cfg: PluginConfig, - } - - fn make_config(name: &str, hooks: Vec<&str>, priority: i32) -> PluginConfig { - PluginConfig { - name: name.to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: hooks.into_iter().map(String::from).collect(), - mode: PluginMode::Sequential, - priority, - on_error: Default::default(), - capabilities: Default::default(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } - } - - impl TestPlugin { - fn new(cfg: PluginConfig) -> Self { - Self { cfg } - } - } - - #[async_trait] - impl Plugin for TestPlugin { - fn config(&self) -> &PluginConfig { - &self.cfg - } - async fn initialize(&self) -> Result<(), Box> { - Ok(()) - } - async fn shutdown(&self) -> Result<(), Box> { - Ok(()) - } - } - - // -- Tests -- - - #[test] - fn test_register_typed_and_lookup() { - let mut reg = PluginRegistry::new(); - let config = make_config("test-plugin", vec!["test_hook"], 10); - let plugin = Arc::new(TestPlugin::new(config.clone())); - let handler: Arc = Arc::new(TestHandler); - - // Use register_for_names_inner directly since we don't have a real HookTypeDef - reg.register_for_names_inner(plugin, config, handler, &["test_hook"]) - .unwrap(); - - assert_eq!(reg.plugin_count(), 1); - assert!(reg.get("test-plugin").is_some()); - assert!(reg.has_hooks_for(&HookType::new("test_hook"))); - assert!(!reg.has_hooks_for(&HookType::new("other_hook"))); - - let entries = reg.entries_for_hook(&HookType::new("test_hook")); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].plugin_ref.name(), "test-plugin"); - } - - #[test] - fn test_register_for_multiple_names() { - let mut reg = PluginRegistry::new(); - let config = make_config("cmf-plugin", vec![], 10); - let plugin = Arc::new(TestPlugin::new(config.clone())); - let handler: Arc = Arc::new(TestHandler); - - reg.register_for_names_inner( - plugin, - config, - handler, - &[ - "cmf.tool_pre_invoke", - "cmf.tool_post_invoke", - "cmf.llm_input", - ], - ) - .unwrap(); - - assert_eq!(reg.plugin_count(), 1); - assert!(reg.has_hooks_for(&HookType::new("cmf.tool_pre_invoke"))); - assert!(reg.has_hooks_for(&HookType::new("cmf.tool_post_invoke"))); - assert!(reg.has_hooks_for(&HookType::new("cmf.llm_input"))); - assert!(!reg.has_hooks_for(&HookType::new("cmf.llm_output"))); - } - - #[test] - fn test_duplicate_registration_fails() { - let mut reg = PluginRegistry::new(); - let c1 = make_config("dup", vec![], 10); - let c2 = make_config("dup", vec![], 20); - let p1 = Arc::new(TestPlugin::new(c1.clone())); - let p2 = Arc::new(TestPlugin::new(c2.clone())); - let h1: Arc = Arc::new(TestHandler); - let h2: Arc = Arc::new(TestHandler); - - assert!(reg - .register_for_names_inner(p1, c1, h1, &["hook_a"]) - .is_ok()); - assert!(reg - .register_for_names_inner(p2, c2, h2, &["hook_a"]) - .is_err()); - } - - #[test] - fn test_priority_ordering_uses_trusted_config() { - let mut reg = PluginRegistry::new(); - let c_low = make_config("low", vec![], 100); - let c_high = make_config("high", vec![], 10); - let p_low = Arc::new(TestPlugin::new(c_low.clone())); - let p_high = Arc::new(TestPlugin::new(c_high.clone())); - let h1: Arc = Arc::new(TestHandler); - let h2: Arc = Arc::new(TestHandler); - - reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]) - .unwrap(); - reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]) - .unwrap(); - - let entries = reg.entries_for_hook(&HookType::new("hook_a")); - assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first - assert_eq!(entries[1].plugin_ref.name(), "low"); // priority 100 second - } - - #[test] - fn test_unregister() { - let mut reg = PluginRegistry::new(); - let config = make_config("removable", vec![], 10); - let plugin = Arc::new(TestPlugin::new(config.clone())); - let handler: Arc = Arc::new(TestHandler); - - reg.register_for_names_inner(plugin, config, handler, &["hook_a"]) - .unwrap(); - - assert_eq!(reg.plugin_count(), 1); - reg.unregister("removable"); - assert_eq!(reg.plugin_count(), 0); - assert!(!reg.has_hooks_for(&HookType::new("hook_a"))); - } - - #[test] - fn test_plugin_ref_id_is_unique() { - let c1 = make_config("a", vec![], 10); - let c2 = make_config("b", vec![], 10); - let p1 = Arc::new(TestPlugin::new(c1.clone())); - let p2 = Arc::new(TestPlugin::new(c2.clone())); - let ref1 = PluginRef::new(p1, c1); - let ref2 = PluginRef::new(p2, c2); - assert_ne!(ref1.id(), ref2.id()); - } - - #[test] - fn test_tampered_plugin_config_ignored() { - let trusted = make_config("sneaky", vec![], 100); - let mut tampered = trusted.clone(); - tampered.priority = 1; - let plugin = Arc::new(TestPlugin::new(tampered)); - - let plugin_ref = PluginRef::new(plugin, trusted); - assert_eq!(plugin_ref.priority(), 100); - } - - #[tokio::test] - async fn test_handler_invoke() { - let handler = TestHandler; - let payload = TestPayload { - value: "test".into(), - }; - let ext = Extensions::default(); - let mut ctx = PluginContext::new(); - - let result = handler - .invoke(&payload as &dyn PluginPayload, &ext, &mut ctx) - .await - .unwrap(); - let fields = crate::executor::extract_erased(result).unwrap(); - assert!(fields.continue_processing); - } -} diff --git a/crates/cpex-core/src/visitor.rs b/crates/cpex-core/src/visitor.rs deleted file mode 100644 index 98651cfd..00000000 --- a/crates/cpex-core/src/visitor.rs +++ /dev/null @@ -1,134 +0,0 @@ -// Location: ./crates/cpex-core/src/visitor.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `ConfigVisitor` — extension point for external orchestrators (APL, -// future Rego/Cedar-direct/custom) to participate in unified-config -// loading without cpex-core taking a dep on any specific orchestrator. -// -// # How it fits -// -// The host calls `PluginManager::load_config_yaml(yaml)`. cpex-core -// parses the YAML twice (once into a typed `CpexConfig`, once into a -// raw `serde_yaml::Value`), runs its own plugin instantiation, then -// walks each registered visitor in registration order: -// -// 1. `visit_plugins` — once per visitor, immediately after -// cpex-core's own plugin instantiation, -// receiving the parsed `&[PluginConfig]` -// so the visitor doesn't have to re-parse -// the root `plugins:` block from raw YAML. -// 2. `visit_global` — global config block -// 3. `visit_default` — once per entity_type with a default -// 4. `visit_policy_bundle` — once per named policy group (tag) -// 5. `visit_route` — once per route -// -// Each visitor sees the **raw YAML** so it can find its own block -// (e.g. `apl:`) under any section without cpex-core having to know -// about it. Parsed sibling data is passed alongside (`RouteEntry` for -// routes) for convenience — e.g. APL needs to know whether a route -// matches `tool:` or `resource:` to build the annotation key. -// -// # Why visit per-section rather than per-whole-config -// -// Visitors typically accumulate state across the hierarchy (e.g. APL's -// visitor compiles globals/defaults/tag-bundles into `CompiledRoute`s -// kept in visitor state, then merges them into each route at -// `visit_route`). Per-section calls give the orchestrator a natural -// place to do that accumulation without re-parsing. -// -// # Visit order -// -// All sections for one visitor run before the next visitor starts. For -// single-visitor deployments (the common case) this is identical to -// any other ordering; for multi-visitor it gives each visitor a -// consistent view of its own internal state. Visitor methods are -// invoked synchronously — no async runtime needed at load time. - -use std::sync::Arc; - -use crate::config::RouteEntry; -use crate::manager::PluginManager; -use crate::plugin::PluginConfig; - -/// Error type returned by a config visitor. Boxed `dyn Error` so each -/// orchestrator can carry its own error variants (parse errors, missing -/// plugin references, etc.) without cpex-core having to enumerate them. -pub type VisitorError = Box; - -/// Extension point for external orchestrators to participate in unified -/// config loading. Register via [`PluginManager::register_visitor`]; -/// invoked during [`PluginManager::load_config_yaml`]. -/// -/// All methods have default no-op implementations — a visitor only -/// overrides the sections it cares about. -pub trait ConfigVisitor: Send + Sync { - /// Stable identifier for diagnostics — included in error contexts - /// if a visitor method returns Err. Convention: short kebab-case - /// matching the orchestrator's YAML key (e.g. `"apl"`, `"rego"`). - fn name(&self) -> &str; - - /// Visit the typed plugin declarations from the root `plugins:` - /// block. Called once per visitor, immediately after cpex-core's - /// own plugin instantiation completes and before any hierarchy - /// section is walked. Visitors that need a per-name registry of - /// hook / capability / on_error metadata can populate it here - /// without re-parsing the YAML — cpex-core has already validated - /// the block (no duplicate names, etc.) by this point. - fn visit_plugins( - &self, - _mgr: &Arc, - _plugins: &[PluginConfig], - ) -> Result<(), VisitorError> { - Ok(()) - } - - /// Visit the top-level `global:` block. `yaml` is the raw value at - /// that path, or `Value::Null` if `global:` is absent. - fn visit_global( - &self, - _mgr: &Arc, - _yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - Ok(()) - } - - /// Visit one entry in `global.defaults`. Called once per - /// `(entity_type, default_block)` pair. `yaml` is the raw value at - /// `global.defaults.`. - fn visit_default( - &self, - _mgr: &Arc, - _entity_type: &str, - _yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - Ok(()) - } - - /// Visit one entry in `global.policies` (a named tag bundle). - /// Called once per `(tag, policy_group)` pair. `yaml` is the raw - /// value at `global.policies.`. - fn visit_policy_bundle( - &self, - _mgr: &Arc, - _tag: &str, - _yaml: &serde_yaml::Value, - ) -> Result<(), VisitorError> { - Ok(()) - } - - /// Visit one route entry. `yaml` is the raw value at `routes[i]` - /// (so orchestrator can find its own block like `apl:`); `parsed` - /// is the typed `RouteEntry` cpex-core deserialized (so the - /// orchestrator can read `tool`/`resource`/`prompt`/`llm`, - /// `meta.scope`, `meta.tags`, etc. without re-parsing). - fn visit_route( - &self, - _mgr: &Arc, - _yaml: &serde_yaml::Value, - _parsed: &RouteEntry, - ) -> Result<(), VisitorError> { - Ok(()) - } -} diff --git a/crates/cpex-core/tests/delegation_e2e.rs b/crates/cpex-core/tests/delegation_e2e.rs deleted file mode 100644 index a403d9e7..00000000 --- a/crates/cpex-core/tests/delegation_e2e.rs +++ /dev/null @@ -1,700 +0,0 @@ -// Location: ./crates/cpex-core/tests/delegation_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end test for the TokenDelegate hook family. -// -// Verifies the host-explicit dispatch model: an outbound caller -// (typically a forwarding-proxy plugin) constructs a -// `DelegationPayload`, calls -// `mgr.invoke_named::(...)`, and reads the -// minted credential out of the returned `PipelineResult`. No -// bespoke method on `PluginManager` — `invoke_named` works -// uniformly because Sequential-phase threading already does the -// right thing for the unified `DelegationPayload`. -// -// Tests cover: -// - Single-handler mint: one plugin produces a `RawDelegatedToken`. -// - Two-handler chain: handler A declines (`delegated_token == None`), -// handler B mints — proves Sequential-phase threading carries -// A's null contribution into B's input. -// - Rejection: handler returns `deny()`; pipeline halts. -// - `from_pipeline_result` returns `None` on deny. -// - Full host flow: invoke delegate, apply to Extensions, observe -// `Extensions.raw_credentials.delegated_tokens` populated under -// the synthesized `DelegationKey`. - -use std::sync::Arc; - -use async_trait::async_trait; - -use chrono::{Duration as ChronoDuration, Utc}; - -use cpex_core::context::PluginContext; -use cpex_core::delegation::{ - AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, - HOOK_TOKEN_DELEGATE, -}; -use cpex_core::error::PluginError; -use cpex_core::extensions::raw_credentials::{DelegationKey, DelegationMode, RawDelegatedToken}; -use cpex_core::extensions::{SecurityExtension, SubjectExtension}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; - -// ===================================================================== -// Plugin fixtures -// ===================================================================== - -/// Minimal RFC-8693-style stub. Doesn't actually exchange anything -/// — just constructs a `RawDelegatedToken` by combining the caller's -/// bearer token with the target audience. Real handlers would call -/// out to an IdP; we only care about wiring here. -struct StubExchanger { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for StubExchanger { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for StubExchanger { - async fn handle( - &self, - payload: &DelegationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - assert!( - !payload.bearer_token().is_empty(), - "exchanger expected non-empty bearer token", - ); - - // Use the route's TTL hint if present; otherwise default to - // 300s. Real handlers would also take min(route_hint, - // idp_response_expires_in). - let ttl_secs = payload - .route_attenuation() - .and_then(|a| a.ttl_seconds) - .unwrap_or(300); - let audience = payload - .target_audience() - .unwrap_or("https://example.com/default") - .to_string(); - // Effective scopes: combine route-attenuation capabilities - // with required_permissions. Real exchangers may narrow - // further based on the IdP's response. - let mut scopes = payload.required_permissions().to_vec(); - if let Some(att) = payload.route_attenuation() { - for cap in &att.capabilities { - if !scopes.contains(cap) { - scopes.push(cap.clone()); - } - } - } - - let minted = RawDelegatedToken::new( - format!("stub-exchanged({})", payload.bearer_token()), - "Authorization", - audience, - scopes, - Utc::now() + ChronoDuration::seconds(ttl_secs as i64), - ); - let mut updated = payload.clone(); - updated.delegated_token = Some(minted); - updated.minted_at = Some(Utc::now()); - PluginResult::modify_payload(updated) - } -} - -/// A handler that always declines — leaves `delegated_token` as -/// `None`. Used to verify chaining: in a chain with a declining -/// primary + a minting fallback, the fallback should see the -/// declined state and mint. -struct DecliningHandler { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for DecliningHandler { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for DecliningHandler { - async fn handle( - &self, - payload: &DelegationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // Returns the payload unchanged — leaves output slots None, - // signals "this handler had nothing to contribute." - let mut updated = payload.clone(); - updated - .metadata - .insert("declined_by".into(), serde_json::json!("declining-handler")); - PluginResult::modify_payload(updated) - } -} - -/// Fallback minter — runs after a declining handler. Asserts that -/// the prior handler's `metadata` contribution survived through -/// Sequential-phase threading (i.e. we see "declined_by") and -/// produces a token in spite of the prior decline. -struct FallbackMinter { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for FallbackMinter { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for FallbackMinter { - async fn handle( - &self, - payload: &DelegationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - assert!( - payload.delegated_token.is_none(), - "fallback minter expected no prior token in chain test", - ); - assert!( - payload.metadata.contains_key("declined_by"), - "fallback minter expected prior handler's metadata in chain", - ); - let mut updated = payload.clone(); - updated.delegated_token = Some(RawDelegatedToken::new( - "fallback-token", - "Authorization", - payload - .target_audience() - .unwrap_or("https://fallback.example.com") - .to_string(), - vec!["read".into()], - Utc::now() + ChronoDuration::seconds(60), - )); - PluginResult::modify_payload(updated) - } -} - -/// Handler that rejects unconditionally. Used to verify the -/// rejection path through `PluginResult::deny`. -struct RejectingHandler { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for RejectingHandler { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RejectingHandler { - async fn handle( - &self, - _payload: &DelegationPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(cpex_core::error::PluginViolation::new( - "delegation.scope_too_broad", - "requested scopes exceed inbound credential's authorization", - )) - } -} - -// ===================================================================== -// Helpers -// ===================================================================== - -fn config(name: &str, priority: i32) -> PluginConfig { - PluginConfig { - name: name.to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], - mode: PluginMode::Sequential, - priority, - on_error: OnError::Fail, - capabilities: Default::default(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } -} - -/// Build the kind of payload a forwarding-proxy plugin would construct -/// just before making a downstream call. -fn build_payload(target: &str, audience: &str, permissions: &[&str]) -> DelegationPayload { - DelegationPayload::new("eyJ.caller.tok", target) - .with_target_type(TargetType::Tool) - .with_target_audience(audience) - .with_required_permissions(permissions.iter().map(|s| s.to_string()).collect()) - .with_auth_enforced_by(AuthEnforcedBy::Target) - .with_route_attenuation(AttenuationConfig { - capabilities: vec!["audit".into()], - resource_template: Some("hr://employees/{{ args.id }}".into()), - actions: vec!["read".into()], - ttl_seconds: Some(120), - }) -} - -fn extract_delegation(result: &cpex_core::executor::PipelineResult) -> DelegationPayload { - DelegationPayload::from_pipeline_result(result) - .expect("PipelineResult had no DelegationPayload — denied or wrong hook type") -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Single handler runs, mints a `RawDelegatedToken`. Host receives -/// the populated payload via `from_pipeline_result`. -#[tokio::test] -async fn single_handler_mints_token() { - let mgr = Arc::new(PluginManager::default()); - let cfg = config("stub-exchanger", 10); - let plugin = Arc::new(StubExchanger { cfg: cfg.clone() }); - mgr.register_handler_for_names::(plugin, cfg, &[HOOK_TOKEN_DELEGATE]) - .unwrap(); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_TOKEN_DELEGATE, - build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ), - Extensions::default(), - None, - ) - .await; - assert!(result.continue_processing); - - let final_payload = extract_delegation(&result); - let token = final_payload - .delegated_token - .as_ref() - .expect("handler should have minted a token"); - - assert_eq!(token.audience, "https://hr.example.com"); - assert_eq!(token.outbound_header, "Authorization"); - assert!(token.scopes.contains(&"read:compensation".to_string())); - // Route attenuation contributed `audit` capability. - assert!(token.scopes.contains(&"audit".to_string())); - // TTL respects the route hint (120s) — token must expire in - // roughly 120s, not 300s default. - let ttl_left = (token.expires_at - Utc::now()).num_seconds(); - assert!( - ttl_left <= 120 && ttl_left > 100, - "token TTL should reflect route hint (~120s); got {}s", - ttl_left, - ); - // Input fields preserved through clone. - assert_eq!(final_payload.bearer_token(), "eyJ.caller.tok"); - assert_eq!(final_payload.target_name(), "get_compensation"); -} - -/// Two-handler chain: declining primary + minting fallback. Proves -/// Sequential-phase threading carries the declining handler's -/// metadata contribution into the fallback handler, and that the -/// fallback's output replaces the lack of a token from the primary. -#[tokio::test] -async fn declining_then_fallback_chain_mints_token() { - let mgr = Arc::new(PluginManager::default()); - - let declining_cfg = config("declining-handler", 10); - let declining = Arc::new(DecliningHandler { - cfg: declining_cfg.clone(), - }); - mgr.register_handler_for_names::( - declining, - declining_cfg, - &[HOOK_TOKEN_DELEGATE], - ) - .unwrap(); - - let fallback_cfg = config("fallback-minter", 20); - let fallback = Arc::new(FallbackMinter { - cfg: fallback_cfg.clone(), - }); - mgr.register_handler_for_names::( - fallback, - fallback_cfg, - &[HOOK_TOKEN_DELEGATE], - ) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_TOKEN_DELEGATE, - build_payload( - "downstream-tool", - "https://downstream.example.com", - &["read"], - ), - Extensions::default(), - None, - ) - .await; - assert!(result.continue_processing); - - let final_payload = extract_delegation(&result); - // Fallback minted a token. - let token = final_payload - .delegated_token - .as_ref() - .expect("fallback should have minted"); - assert_eq!(&*token.token, "fallback-token"); - // Declining handler's metadata survived. - assert_eq!( - final_payload.metadata.get("declined_by"), - Some(&serde_json::json!("declining-handler")), - ); -} - -/// Rejecting handler short-circuits via `PluginResult::deny`. Pipeline -/// halts; violation surfaces in `PipelineResult.violation`. -#[tokio::test] -async fn rejecting_handler_halts_pipeline() { - let mgr = Arc::new(PluginManager::default()); - let cfg = config("rejecting-handler", 10); - let plugin = Arc::new(RejectingHandler { cfg: cfg.clone() }); - mgr.register_handler_for_names::(plugin, cfg, &[HOOK_TOKEN_DELEGATE]) - .unwrap(); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_TOKEN_DELEGATE, - build_payload("tool", "https://aud.example.com", &["read"]), - Extensions::default(), - None, - ) - .await; - assert!(!result.continue_processing); - // from_pipeline_result returns None on deny — host's signal that - // no token was minted. - assert!(DelegationPayload::from_pipeline_result(&result).is_none()); - let violation = result.violation.expect("rejection should surface"); - assert_eq!(violation.code, "delegation.scope_too_broad"); -} - -/// Full host-side flow: a request already has a resolved subject in -/// `Extensions.security.subject` (from a prior IdentityResolve pass); -/// the outbound forwarding plugin invokes TokenDelegate; the host -/// applies the result back to Extensions; the minted token now lives -/// in `Extensions.raw_credentials.delegated_tokens` keyed by a -/// `DelegationKey` that incorporates the subject id. -#[tokio::test] -async fn apply_to_extensions_writes_delegated_token_keyed_by_subject() { - let mgr = Arc::new(PluginManager::default()); - let cfg = config("stub-exchanger", 10); - let plugin = Arc::new(StubExchanger { cfg: cfg.clone() }); - mgr.register_handler_for_names::(plugin, cfg, &[HOOK_TOKEN_DELEGATE]) - .unwrap(); - mgr.initialize().await.unwrap(); - - // Initial extensions: identity has already populated subject. - let initial_ext = Extensions { - security: Some(Arc::new(SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice@corp.com".into()), - ..Default::default() - }), - ..Default::default() - })), - ..Default::default() - }; - - let (result, _bg) = mgr - .invoke_named::( - HOOK_TOKEN_DELEGATE, - build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ), - initial_ext.clone(), - None, - ) - .await; - assert!(result.continue_processing); - - let delegation = extract_delegation(&result); - let updated_ext = delegation.apply_to_extensions(initial_ext); - - // Minted token now lives in Extensions.raw_credentials.delegated_tokens. - let raw = updated_ext - .raw_credentials - .as_ref() - .expect("raw_credentials slot populated"); - assert_eq!(raw.delegated_tokens.len(), 1); - - // The key is synthesized from (subject.id, workload, client, audience, - // scopes, mode). Built via the constructor — DelegationKey is - // #[non_exhaustive]; no workload/client participated, so only the - // subject is set. - let expected_key = DelegationKey::new( - DelegationMode::OnBehalfOfUser, - "https://hr.example.com", - // Order matches what StubExchanger produces (required_permissions - // first, then attenuation capabilities). - vec!["read:compensation".into(), "audit".into()], - ) - .with_subject_id("alice@corp.com"); - assert!( - raw.delegated_tokens.contains_key(&expected_key), - "delegated_tokens missing expected key; saw keys: {:?}", - raw.delegated_tokens.keys().collect::>(), - ); - - // Subject from the prior identity pass survived apply. - let sec = updated_ext.security.as_ref().unwrap(); - assert_eq!( - sec.subject.as_ref().unwrap().id.as_deref(), - Some("alice@corp.com"), - ); -} - -/// Load-bearing integration test: the full host flow from token -/// delegation through downstream CMF dispatch correctly cap-gates -/// the `delegated_tokens` slot. -/// -/// Mirrors the `cap_gating_post_apply_through_cmf_dispatch` -/// test but for the *outbound* leg: -/// 1. TokenDelegate handler mints a downstream credential. -/// 2. Host applies the resolved payload back to `Extensions` via -/// `apply_to_extensions` — the minted token lands in -/// `Extensions.raw_credentials.delegated_tokens`. -/// 3. Host invokes `cmf.tool_pre_invoke` (the next outbound step, -/// typically where a forwarding proxy attaches the credential). -/// Two registered CMF plugins: -/// - `DelegatedTokenReader` declares `read_delegated_tokens` -/// — must observe one minted token. -/// - `DelegatedTokenBlind` declares no credential capability -/// — must observe `raw_credentials == None` because -/// `filter_extensions` strips the slot. -/// -/// Validates the symmetric story to identity's `read_inbound_credentials` -/// gating: only forwarding plugins (audit-trail consumers, proxies) -/// that explicitly declare the cap can see the minted credentials. -#[tokio::test] -async fn cap_gating_post_apply_through_cmf_dispatch() { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - use cpex_core::cmf::enums::Role; - use cpex_core::cmf::{CmfHook, Message, MessagePayload}; - - // ----- CMF plugin WITH read_delegated_tokens ----- - struct DelegatedTokenReader { - cfg: PluginConfig, - saw_token_count: Arc, - } - #[async_trait] - impl Plugin for DelegatedTokenReader { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - impl HookHandler for DelegatedTokenReader { - async fn handle( - &self, - _payload: &MessagePayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let n = ext - .raw_credentials - .as_ref() - .map(|r| r.delegated_tokens.len()) - .unwrap_or(0); - self.saw_token_count.store(n, Ordering::SeqCst); - PluginResult::allow() - } - } - - // ----- CMF plugin WITHOUT credential caps ----- - struct DelegatedTokenBlind { - cfg: PluginConfig, - saw_any: Arc, - } - #[async_trait] - impl Plugin for DelegatedTokenBlind { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - impl HookHandler for DelegatedTokenBlind { - async fn handle( - &self, - _payload: &MessagePayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - self.saw_any - .store(ext.raw_credentials.is_some(), Ordering::SeqCst); - PluginResult::allow() - } - } - - // ----- Wire everything up ----- - let mgr = Arc::new(PluginManager::default()); - - // TokenDelegate handler. - let td_cfg = config("stub-exchanger", 10); - let td_plugin = Arc::new(StubExchanger { - cfg: td_cfg.clone(), - }); - mgr.register_handler_for_names::( - td_plugin, - td_cfg, - &[HOOK_TOKEN_DELEGATE], - ) - .unwrap(); - - // CMF reader — declares read_delegated_tokens. Also declares - // read_subject so the handler can verify subject still visible - // through the request lifecycle. - let reader_saw_count = Arc::new(AtomicUsize::new(usize::MAX)); - let reader_cfg = PluginConfig { - name: "delegated-reader".into(), - kind: "test".into(), - description: None, - author: None, - version: None, - hooks: vec!["cmf.tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: ["read_delegated_tokens", "read_subject"] - .iter() - .map(|s| s.to_string()) - .collect(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - }; - mgr.register_handler_for_names::( - Arc::new(DelegatedTokenReader { - cfg: reader_cfg.clone(), - saw_token_count: Arc::clone(&reader_saw_count), - }), - reader_cfg, - &["cmf.tool_pre_invoke"], - ) - .unwrap(); - - // CMF blind — no cred caps. - let blind_saw = Arc::new(AtomicBool::new(false)); - let blind_cfg = PluginConfig { - name: "delegated-blind".into(), - kind: "test".into(), - description: None, - author: None, - version: None, - hooks: vec!["cmf.tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 20, - on_error: OnError::Fail, - capabilities: Default::default(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - }; - mgr.register_handler_for_names::( - Arc::new(DelegatedTokenBlind { - cfg: blind_cfg.clone(), - saw_any: Arc::clone(&blind_saw), - }), - blind_cfg, - &["cmf.tool_pre_invoke"], - ) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // ----- Host flow ----- - // 1. Initial Extensions has a subject (typically from a prior - // IdentityResolve pass). - let initial_ext = Extensions { - security: Some(Arc::new(SecurityExtension { - subject: Some(SubjectExtension { - id: Some("alice@corp.com".into()), - ..Default::default() - }), - ..Default::default() - })), - ..Default::default() - }; - - // 2. Token delegation. - let (td_result, _bg) = mgr - .invoke_named::( - HOOK_TOKEN_DELEGATE, - build_payload( - "get_compensation", - "https://hr.example.com", - &["read:compensation"], - ), - initial_ext.clone(), - None, - ) - .await; - assert!(td_result.continue_processing); - let delegation = - DelegationPayload::from_pipeline_result(&td_result).expect("delegation should have minted"); - - // 3. Apply. - let updated_ext = delegation.apply_to_extensions(initial_ext); - - // 4. Dispatch through CMF. - let cmf_payload = MessagePayload { - message: Message::text(Role::User, "fetch compensation"), - }; - let (cmf_result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload, updated_ext, None) - .await; - assert!( - cmf_result.continue_processing, - "CMF dispatch should not be blocked: violation = {:?}", - cmf_result.violation, - ); - - // ----- Verifications ----- - // Plugin with cap saw the minted token. - assert_eq!( - reader_saw_count.load(Ordering::SeqCst), - 1, - "DelegatedTokenReader with read_delegated_tokens should see 1 token", - ); - // Plugin without cap saw no raw_credentials at all. - assert!( - !blind_saw.load(Ordering::SeqCst), - "DelegatedTokenBlind without credential caps must NOT see raw_credentials", - ); -} - -// PluginError kept imported so a future test wanting to assert on a -// specific error variant can use it without an extra `use` line. -#[allow(dead_code)] -fn _force_plugin_error_link(_e: PluginError) {} diff --git a/crates/cpex-core/tests/identity_e2e.rs b/crates/cpex-core/tests/identity_e2e.rs deleted file mode 100644 index 68823f39..00000000 --- a/crates/cpex-core/tests/identity_e2e.rs +++ /dev/null @@ -1,745 +0,0 @@ -// Location: ./crates/cpex-core/tests/identity_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end test for the IdentityResolve hook family. -// -// Verifies the host-explicit dispatch model: the host constructs an -// `IdentityPayload`, calls `mgr.invoke_named::(...)`, -// and reads the populated identity slots back out of the returned -// `PipelineResult.modified_payload`. No bespoke `resolve_identity` -// method on `PluginManager` — `invoke_named` works for `IdentityHook` -// like every other hook, because Sequential-phase threading already -// does the right thing for the unified `IdentityPayload` -// (input + accumulator in one struct). -// -// Tests cover: -// - Single-handler resolve: one plugin populates `subject`. -// - Two-handler chain: plugin A populates `subject`, plugin B -// receives A's output and populates `caller_workload`. Final -// payload carries both — proves Sequential-phase threading. -// - In-band rejection: a handler sets `rejected = true`; the -// pipeline halts; status + reason flow back to the caller. - -use std::sync::Arc; - -use async_trait::async_trait; - -use cpex_core::context::PluginContext; -use cpex_core::error::PluginError; -use cpex_core::extensions::{SubjectExtension, WorkloadIdentity}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; - -// ===================================================================== -// Plugin fixtures -// ===================================================================== - -/// A fake JWT resolver. Doesn't actually validate anything — just -/// asserts a non-empty `raw_token()` and writes a hard-coded subject. -/// Real resolvers would parse + validate the token; for wiring tests -/// we only care that the handler receives the right payload shape -/// and that its output flows back through Sequential-phase threading. -struct SubjectResolver { - cfg: PluginConfig, - subject_id: String, -} - -#[async_trait] -impl Plugin for SubjectResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for SubjectResolver { - async fn handle( - &self, - payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - assert!( - !payload.raw_token().is_empty(), - "subject resolver expected a non-empty token", - ); - let mut updated = payload.clone(); - updated.subject = Some(SubjectExtension { - id: Some(self.subject_id.clone()), - ..Default::default() - }); - PluginResult::modify_payload(updated) - } -} - -/// Workload resolver. Pulls a SPIFFE-ID out of (in real life) -/// `X-Forwarded-Client-Cert`; here we read it from the -/// `IdentityPayload.headers()` map and hand-roll a `WorkloadIdentity`. -/// Critical assertion for the chaining test: when this runs *after* -/// `SubjectResolver`, it must see `payload.subject` already populated -/// — proves Sequential-phase threading carries plugin 1's output -/// forward into plugin 2's input. -struct WorkloadResolver { - cfg: PluginConfig, - require_prior_subject: bool, -} - -#[async_trait] -impl Plugin for WorkloadResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for WorkloadResolver { - async fn handle( - &self, - payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - if self.require_prior_subject { - assert!( - payload.subject.is_some(), - "workload resolver expected prior subject in chained run", - ); - } - let spiffe_id = payload - .headers() - .get("x-spiffe-id") - .cloned() - .unwrap_or_else(|| "spiffe://example.com/unknown".to_string()); - let mut updated = payload.clone(); - updated.caller_workload = Some(WorkloadIdentity { - spiffe_id: Some(spiffe_id), - trust_domain: Some("example.com".to_string()), - ..Default::default() - }); - PluginResult::modify_payload(updated) - } -} - -/// Handler that always rejects. Used to verify the in-band rejection -/// pathway: setting `rejected = true` on the returned payload (and -/// using `PluginResult::deny`) must halt the pipeline. -struct RejectingResolver { - cfg: PluginConfig, -} - -#[async_trait] -impl Plugin for RejectingResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RejectingResolver { - async fn handle( - &self, - _payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - PluginResult::deny(cpex_core::error::PluginViolation::new( - "auth.expired", - "token expired", - )) - } -} - -// ===================================================================== -// Helpers -// ===================================================================== - -fn config(name: &str, priority: i32) -> PluginConfig { - PluginConfig { - name: name.to_string(), - kind: "test".to_string(), - description: None, - author: None, - version: None, - hooks: vec![HOOK_IDENTITY_RESOLVE.to_string()], - mode: PluginMode::Sequential, - priority, - on_error: OnError::Fail, - capabilities: Default::default(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - } -} - -/// Build the payload the way a host normally would: raw token from -/// `Authorization`, headers preserved, source set. Identity handlers -/// downstream read these via the public accessors. -fn build_payload(token: &str) -> IdentityPayload { - let mut headers = std::collections::HashMap::new(); - headers.insert("authorization".to_string(), format!("Bearer {}", token)); - headers.insert( - "x-spiffe-id".to_string(), - "spiffe://example.com/agent-1".to_string(), - ); - IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers) -} - -/// Shortcut around `IdentityPayload::from_pipeline_result` for tests -/// that know the result must be present and well-typed. -fn extract_identity(result: &cpex_core::executor::PipelineResult) -> IdentityPayload { - IdentityPayload::from_pipeline_result(result) - .expect("PipelineResult had no IdentityPayload — denied or wrong hook type") -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Single handler runs, populates subject. Host receives an -/// `IdentityPayload` with subject populated; input fields survive -/// the chain unchanged. -#[tokio::test] -async fn single_resolver_populates_subject() { - let mgr = Arc::new(PluginManager::default()); - let cfg = config("subject-resolver", 10); - let plugin = Arc::new(SubjectResolver { - cfg: cfg.clone(), - subject_id: "alice@corp.com".to_string(), - }); - mgr.register_handler::(plugin, cfg) - .unwrap(); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - Extensions::default(), - None, - ) - .await; - - assert!(result.continue_processing, "pipeline should allow"); - let final_payload = extract_identity(&result); - - // Output populated by the handler. - assert_eq!( - final_payload.subject.as_ref().unwrap().id.as_deref(), - Some("alice@corp.com"), - ); - - // Input fields preserved through Sequential threading + clone. - assert_eq!(final_payload.raw_token(), "eyJ.fake.jwt"); - assert_eq!(final_payload.source_header(), Some("Authorization")); -} - -/// Two handlers in priority order. Handler 1 writes subject; handler -/// 2 — running after — must see subject already populated (via the -/// `require_prior_subject` assertion in its handler). Final payload -/// carries both contributions. -/// -/// This is the load-bearing test for the whole design: it proves -/// that Sequential-phase threading is exactly what the multi-handler -/// composition model needs, without any framework changes beyond -/// what already exists for CMF. -#[tokio::test] -async fn two_resolvers_chain_populates_both_slots() { - let mgr = Arc::new(PluginManager::default()); - - let subject_cfg = config("subject-resolver", 10); - let subject = Arc::new(SubjectResolver { - cfg: subject_cfg.clone(), - subject_id: "alice@corp.com".to_string(), - }); - mgr.register_handler::(subject, subject_cfg) - .unwrap(); - - let workload_cfg = config("workload-resolver", 20); // runs after subject - let workload = Arc::new(WorkloadResolver { - cfg: workload_cfg.clone(), - require_prior_subject: true, - }); - mgr.register_handler::(workload, workload_cfg) - .unwrap(); - - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - Extensions::default(), - None, - ) - .await; - - assert!(result.continue_processing, "pipeline should allow"); - let final_payload = extract_identity(&result); - - // Subject from plugin 1 survived plugin 2's pass. - assert_eq!( - final_payload.subject.as_ref().unwrap().id.as_deref(), - Some("alice@corp.com"), - ); - - // Workload added by plugin 2. - let workload = final_payload - .caller_workload - .as_ref() - .expect("workload resolver should have populated caller_workload"); - assert_eq!( - workload.spiffe_id.as_deref(), - Some("spiffe://example.com/agent-1"), - ); - - // Original input fields still intact. - assert_eq!(final_payload.raw_token(), "eyJ.fake.jwt"); -} - -/// Rejecting handler short-circuits the pipeline. `continue_processing` -/// is `false`; the violation surfaces in `PipelineResult.violation`. -/// Hosts use this to skip downstream tool invocation and return -/// a 401/403 to the client. -#[tokio::test] -async fn rejecting_resolver_halts_pipeline() { - let mgr = Arc::new(PluginManager::default()); - let cfg = config("rejecting-resolver", 10); - let plugin = Arc::new(RejectingResolver { cfg: cfg.clone() }); - mgr.register_handler::(plugin, cfg) - .unwrap(); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.expired.jwt"), - Extensions::default(), - None, - ) - .await; - - assert!(!result.continue_processing, "rejection should halt"); - let violation = result.violation.expect("rejected → violation present"); - assert_eq!(violation.code, "auth.expired"); - assert_eq!(violation.reason, "token expired"); -} - -/// Full host-side flow: invoke identity, apply the resolved payload -/// back to the `Extensions`, observe that the identity slots are now -/// populated on `Extensions.security.*` / `Extensions.raw_credentials`. -/// Downstream `cmf.tool_pre_invoke` would now see the resolved subject -/// — that's the whole point of having an identity hook. -/// -/// Also exercises the slice-1 invariant that pre-existing security -/// fields (labels, classification) survive the apply step — the -/// host shouldn't lose its earlier annotations just because identity -/// landed. -#[tokio::test] -async fn apply_to_extensions_populates_security_and_preserves_existing_fields() { - use cpex_core::extensions::raw_credentials::{ - RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, - }; - use cpex_core::extensions::SecurityExtension; - - // ----- Handler: produces a subject + a RawCredentialsExtension ----- - struct FullResolver { - cfg: PluginConfig, - } - #[async_trait] - impl Plugin for FullResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - impl HookHandler for FullResolver { - async fn handle( - &self, - payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let token_bytes = payload.raw_token().to_string(); - let mut updated = payload.clone(); - updated.subject = Some(SubjectExtension { - id: Some("alice@corp.com".into()), - ..Default::default() - }); - // Stash the validated token under TokenRole::User so a - // forwarding plugin can re-attach it later. - let mut raw = RawCredentialsExtension::default(); - raw.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new(token_bytes, "Authorization", TokenKind::Jwt), - ); - updated.raw_credentials = Some(raw); - PluginResult::modify_payload(updated) - } - } - - let mgr = Arc::new(PluginManager::default()); - let cfg = config("full-resolver", 10); - let plugin = Arc::new(FullResolver { cfg: cfg.clone() }); - mgr.register_handler::(plugin, cfg) - .unwrap(); - mgr.initialize().await.unwrap(); - - // ----- Host's initial Extensions carries a pre-existing label ----- - // We need to verify that applying the identity result doesn't - // clobber the label — identity should only touch identity slots. - let mut initial_security = SecurityExtension::default(); - initial_security.add_label("PII"); - initial_security.classification = Some("internal".into()); - let initial_ext = Extensions { - security: Some(Arc::new(initial_security)), - ..Default::default() - }; - - // ----- Run identity resolution ----- - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - initial_ext.clone(), - None, - ) - .await; - - assert!(result.continue_processing); - - // ----- Apply back to Extensions ----- - let final_payload = extract_identity(&result); - let updated_ext = final_payload.apply_to_extensions(initial_ext); - - // Identity slots populated on security. - let sec = updated_ext - .security - .as_ref() - .expect("security slot present"); - assert_eq!( - sec.subject.as_ref().unwrap().id.as_deref(), - Some("alice@corp.com"), - ); - - // Pre-existing fields preserved — this is the load-bearing - // assertion for the merge-not-replace semantics. - assert!(sec.has_label("PII"), "pre-existing label survived apply"); - assert_eq!(sec.classification.as_deref(), Some("internal")); - - // RawCredentials surfaced into Extensions. - let raw = updated_ext - .raw_credentials - .as_ref() - .expect("raw_credentials slot present"); - let user_token = raw - .inbound_tokens - .get(&TokenRole::User) - .expect("user token present"); - assert_eq!(user_token.source_header, "Authorization"); - // Token bytes carried over end-to-end. This works because nothing - // on this path serializes the extension — the `#[serde(skip)]` on - // the token field would strip the bytes if it did. A host that - // needs the plaintext in another process reads this field directly - // and carries it on a capability-gated side channel; see - // `RawCredentialsExtension`'s module docs. - assert_eq!(&*user_token.token, "eyJ.fake.jwt"); -} - -/// When the IdentityHook chain is denied, `from_pipeline_result` -/// returns `None` because the executor produces no `modified_payload` -/// on the deny path. Hosts use this to distinguish "identity -/// resolved" from "identity rejected" without a separate type. -#[tokio::test] -async fn from_pipeline_result_returns_none_on_deny() { - let mgr = Arc::new(PluginManager::default()); - let cfg = config("rejecter", 10); - let plugin = Arc::new(RejectingResolver { cfg: cfg.clone() }); - mgr.register_handler::(plugin, cfg) - .unwrap(); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.tok"), - Extensions::default(), - None, - ) - .await; - assert!(!result.continue_processing); - assert!(IdentityPayload::from_pipeline_result(&result).is_none()); -} - -/// Load-bearing integration test: the full host flow from identity -/// resolution through CMF dispatch correctly cap-gates the -/// `raw_credentials` slot. -/// -/// Scenario: -/// 1. IdentityResolve handler populates `subject` + a -/// RawCredentialsExtension with a User token. -/// 2. Host applies the resolved payload back to `Extensions` via -/// `apply_to_extensions`, getting a fully-populated request -/// Extensions container. -/// 3. Host invokes `cmf.tool_pre_invoke` against two registered -/// CMF plugins: -/// - `InboundReader` declares `read_inbound_credentials` — -/// must observe `raw_credentials` with one token. -/// - `InboundBlind` declares no credential capability — -/// must observe `raw_credentials == None` because the -/// executor's `filter_extensions` strips the slot. -/// -/// Proves end-to-end that cap-gating is honored when the identity -/// hook's output flows through the host's apply-then-dispatch path. -/// The unit tests in `extensions/filter.rs` exercise the gate in -/// isolation; this test pins the wiring through the real executor. -#[tokio::test] -async fn cap_gating_post_apply_through_cmf_dispatch() { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use std::sync::Mutex; - - use cpex_core::cmf::enums::Role; - use cpex_core::cmf::{CmfHook, Message, MessagePayload}; - use cpex_core::extensions::raw_credentials::{ - RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, - }; - use cpex_core::extensions::SecurityExtension; - - // ----- Identity resolver: populates subject + one inbound token ----- - struct FullResolver { - cfg: PluginConfig, - } - #[async_trait] - impl Plugin for FullResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - impl HookHandler for FullResolver { - async fn handle( - &self, - payload: &IdentityPayload, - _ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - let token = payload.raw_token().to_string(); - let mut updated = payload.clone(); - updated.subject = Some(SubjectExtension { - id: Some("alice@corp.com".into()), - ..Default::default() - }); - let mut raw = RawCredentialsExtension::default(); - raw.inbound_tokens.insert( - TokenRole::User, - RawInboundToken::new(token, "Authorization", TokenKind::Jwt), - ); - updated.raw_credentials = Some(raw); - PluginResult::modify_payload(updated) - } - } - - // ----- CMF plugin WITH read_inbound_credentials ----- - // Writes 1 if it saw a token, 0 if it saw none. - struct InboundReader { - cfg: PluginConfig, - saw_token_count: Arc, - saw_subject_id: Arc>>, - } - #[async_trait] - impl Plugin for InboundReader { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - impl HookHandler for InboundReader { - async fn handle( - &self, - _payload: &MessagePayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // Should see the token — plugin declared the cap. - let n = ext - .raw_credentials - .as_ref() - .map(|r| r.inbound_tokens.len()) - .unwrap_or(0); - self.saw_token_count.store(n, Ordering::SeqCst); - // Subject also visible — read_subject gives id+type baseline. - let id = ext - .security - .as_ref() - .and_then(|s| s.subject.as_ref()) - .and_then(|s| s.id.clone()); - *self.saw_subject_id.lock().unwrap() = id; - PluginResult::allow() - } - } - - // ----- CMF plugin WITHOUT credential caps ----- - // Records whether it observed raw_credentials (it shouldn't). - struct InboundBlind { - cfg: PluginConfig, - saw_any_credentials: Arc, - } - #[async_trait] - impl Plugin for InboundBlind { - fn config(&self) -> &PluginConfig { - &self.cfg - } - } - impl HookHandler for InboundBlind { - async fn handle( - &self, - _payload: &MessagePayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - // raw_credentials must be None — filter_extensions strips - // the slot when neither sub-cap is held. - self.saw_any_credentials - .store(ext.raw_credentials.is_some(), Ordering::SeqCst); - PluginResult::allow() - } - } - - // ----- Wire it all up ----- - let mgr = Arc::new(PluginManager::default()); - - // IdentityHook handler. - let id_cfg = config("full-resolver", 10); - mgr.register_handler::( - Arc::new(FullResolver { - cfg: id_cfg.clone(), - }), - id_cfg, - ) - .unwrap(); - - // CMF plugins. Both register against cmf.tool_pre_invoke; they - // run in priority order during the same invoke. - let reader_saw_count = Arc::new(AtomicUsize::new(usize::MAX)); // sentinel - let reader_saw_subject = Arc::new(Mutex::new(None)); - let reader_cfg = PluginConfig { - name: "inbound-reader".into(), - kind: "test".into(), - description: None, - author: None, - version: None, - hooks: vec!["cmf.tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - capabilities: ["read_inbound_credentials", "read_subject"] - .iter() - .map(|s| s.to_string()) - .collect(), - tags: Vec::new(), - conditions: Vec::new(), - config: None, - }; - mgr.register_handler_for_names::( - Arc::new(InboundReader { - cfg: reader_cfg.clone(), - saw_token_count: Arc::clone(&reader_saw_count), - saw_subject_id: Arc::clone(&reader_saw_subject), - }), - reader_cfg, - &["cmf.tool_pre_invoke"], - ) - .unwrap(); - - let blind_saw_creds = Arc::new(AtomicBool::new(false)); - let blind_cfg = PluginConfig { - name: "inbound-blind".into(), - kind: "test".into(), - description: None, - author: None, - version: None, - hooks: vec!["cmf.tool_pre_invoke".into()], - mode: PluginMode::Sequential, - priority: 20, - on_error: OnError::Fail, - capabilities: Default::default(), // no caps - tags: Vec::new(), - conditions: Vec::new(), - config: None, - }; - mgr.register_handler_for_names::( - Arc::new(InboundBlind { - cfg: blind_cfg.clone(), - saw_any_credentials: Arc::clone(&blind_saw_creds), - }), - blind_cfg, - &["cmf.tool_pre_invoke"], - ) - .unwrap(); - - mgr.initialize().await.unwrap(); - - // ----- Host flow ----- - // 1. Initial Extensions carrying a label — verifies later that - // apply_to_extensions doesn't clobber pre-existing security - // fields when populating identity slots. - let mut initial_security = SecurityExtension::default(); - initial_security.add_label("PII"); - let initial_ext = Extensions { - security: Some(Arc::new(initial_security)), - ..Default::default() - }; - - // 2. Identity resolution. - let (id_result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - initial_ext.clone(), - None, - ) - .await; - assert!(id_result.continue_processing); - let identity = - IdentityPayload::from_pipeline_result(&id_result).expect("identity should have resolved"); - - // 3. Apply. - let updated_ext = identity.apply_to_extensions(initial_ext); - - // 4. Dispatch through CMF. Both plugins run; each sees the - // capability-filtered view of `updated_ext`. - let cmf_payload = MessagePayload { - message: Message::text(Role::User, "fetch sensitive data"), - }; - let (cmf_result, _bg) = mgr - .invoke_named::("cmf.tool_pre_invoke", cmf_payload, updated_ext, None) - .await; - assert!( - cmf_result.continue_processing, - "CMF dispatch should not be blocked: violation = {:?}", - cmf_result.violation, - ); - - // ----- Verifications ----- - // Plugin with cap saw the inbound token. - assert_eq!( - reader_saw_count.load(Ordering::SeqCst), - 1, - "InboundReader with read_inbound_credentials should see 1 token", - ); - // Plugin with cap also saw the resolved subject (read_subject baseline). - assert_eq!( - reader_saw_subject.lock().unwrap().as_deref(), - Some("alice@corp.com"), - ); - // Plugin without cap saw nothing — filter_extensions stripped the slot. - assert!( - !blind_saw_creds.load(Ordering::SeqCst), - "InboundBlind without credential caps must NOT see raw_credentials", - ); -} - -// PluginError import only exists to keep the dev-dep on cpex-core -// honest if a future test needs it; unused for now. -#[allow(dead_code)] -fn _force_plugin_error_link(_e: PluginError) {} diff --git a/crates/cpex-core/tests/identity_route_e2e.rs b/crates/cpex-core/tests/identity_route_e2e.rs deleted file mode 100644 index 03f27242..00000000 --- a/crates/cpex-core/tests/identity_route_e2e.rs +++ /dev/null @@ -1,864 +0,0 @@ -// Location: ./crates/cpex-core/tests/identity_route_e2e.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// End-to-end tests for the route-level `authentication:` block. -// -// Verifies the hook-specific binding semantics: -// * A route's `authentication:` block is the authoritative dispatch list -// for the `identity.resolve` hook on that route. -// * The route's `plugins:` block (which means "per-route overrides" -// in APL-driven routes, "per-route binding" otherwise) does NOT -// bind plugins for the `identity.resolve` hook. -// * Dispatch order matches the order steps are declared in -// `authentication:`, NOT the plugins' chain-priority values. -// * Per-step config overrides flow through the existing -// `create_override_instance` pathway. -// -// Companion tests for IdentityHook *semantics* (payload threading, -// rejection, apply_to_extensions) live in `identity_e2e.rs`. - -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; - -use cpex_core::config; -use cpex_core::context::PluginContext; -use cpex_core::extensions::{MetaExtension, SubjectExtension}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; -use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; -use cpex_core::manager::PluginManager; -use cpex_core::plugin::{Plugin, PluginConfig}; -use cpex_core::registry::AnyHookHandler; - -// ===================================================================== -// Test plugin: a recording identity resolver -// ===================================================================== -// -// Each instance writes its own name to a shared `Vec` ledger -// when invoked. That lets tests assert (a) which plugins fired and -// (b) in what order. Also stamps `subject.id` so the post-pipeline -// payload reflects who ran last — useful for verifying that the -// chain produced the expected accumulated state. - -struct RecordingResolver { - cfg: PluginConfig, - name: String, - ledger: Arc>>, - /// Number of times this instance has been invoked. Used to verify - /// that per-step config overrides actually produce a fresh instance - /// rather than reusing the base. - invocation_count: Arc, - /// Optional sink for what `Extensions` slots the plugin saw on - /// invocation. Used by cap-gating tests. `None` when the test - /// doesn't care about visibility. - extensions_observation: Arc>>, -} - -/// What an identity resolver saw in `Extensions` during invocation — -/// drives the cap-gating tests. Only includes slots the tests check -/// (security.subject id, labels). -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct IdentityExtensionsObservation { - saw_subject_id: Option, - saw_labels: Vec, -} - -#[async_trait] -impl Plugin for RecordingResolver { - fn config(&self) -> &PluginConfig { - &self.cfg - } -} - -impl HookHandler for RecordingResolver { - async fn handle( - &self, - payload: &IdentityPayload, - ext: &Extensions, - _ctx: &mut PluginContext, - ) -> PluginResult { - self.ledger.lock().unwrap().push(self.name.clone()); - self.invocation_count.fetch_add(1, Ordering::SeqCst); - - // Capability-gating observation. cpex-core's executor calls - // `filter_extensions(&ext, &caps)` BEFORE handing us `ext`, - // so this snapshot reflects exactly what our declared - // capabilities expose. - *self.extensions_observation.lock().unwrap() = Some(IdentityExtensionsObservation { - saw_subject_id: ext - .security - .as_ref() - .and_then(|s| s.subject.as_ref()) - .and_then(|s| s.id.clone()), - saw_labels: ext - .security - .as_ref() - .map(|s| s.labels.iter().cloned().collect()) - .unwrap_or_default(), - }); - - let mut updated = payload.clone(); - updated.subject = Some(SubjectExtension { - id: Some(self.name.clone()), - ..Default::default() - }); - PluginResult::modify_payload(updated) - } -} - -// ===================================================================== -// Test factory — used to build plugin instances from a config block -// so route-level `config:` overrides can produce fresh instances via -// `create_override_instance`. -// ===================================================================== - -struct RecordingFactory { - ledger: Arc>>, - /// Count of *factory invocations* (i.e. instance constructions). - /// Distinct from `invocation_count` on individual plugins — - /// asserts that a config override produced a NEW instance. - factory_calls: Arc, - /// Optional shared observation sink — when set, every plugin - /// the factory builds writes its extensions-view snapshot here - /// on invocation. The test holds the same Arc and reads it - /// after dispatch. `None` means observations are off (existing - /// tests don't need them and shouldn't pay the wiring cost). - observation_sink: Option>>>, -} - -impl PluginFactory for RecordingFactory { - fn create( - &self, - config: &PluginConfig, - ) -> Result> { - self.factory_calls.fetch_add(1, Ordering::SeqCst); - let plugin = Arc::new(RecordingResolver { - cfg: config.clone(), - name: config.name.clone(), - ledger: Arc::clone(&self.ledger), - invocation_count: Arc::new(AtomicUsize::new(0)), - extensions_observation: self - .observation_sink - .clone() - .unwrap_or_else(|| Arc::new(Mutex::new(None))), - }); - let adapter: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); - Ok(PluginInstance { - plugin: plugin as Arc, - handlers: vec![(HOOK_IDENTITY_RESOLVE, adapter)], - }) - } -} - -// ===================================================================== -// Test helpers -// ===================================================================== - -/// Build the request Extensions with MetaExtension set so route -/// filtering kicks in. Without `meta`, the filter falls through to -/// chain dispatch (all entries returned) — that's the wrong code -/// path to be testing. -fn ext_for_tool(tool_name: &str) -> Extensions { - Extensions { - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".to_string()), - entity_name: Some(tool_name.to_string()), - ..Default::default() - })), - ..Default::default() - } -} - -fn build_payload(token: &str) -> IdentityPayload { - IdentityPayload::new(token, TokenSource::Bearer) -} - -/// Standard set-up: PluginManager with the recording factory -/// registered, plus a shared ledger and factory-call counter the -/// test asserts on. Doesn't wire extensions observation — -/// existing tests don't need it. -fn manager_with_recording_factory() -> ( - Arc, - Arc>>, - Arc, -) { - let ledger = Arc::new(Mutex::new(Vec::new())); - let factory_calls = Arc::new(AtomicUsize::new(0)); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "recording", - Box::new(RecordingFactory { - ledger: Arc::clone(&ledger), - factory_calls: Arc::clone(&factory_calls), - observation_sink: None, - }), - ); - (mgr, ledger, factory_calls) -} - -/// Cap-gating-flavored set-up: also returns a shared `observation_sink` -/// the test holds onto so it can inspect what extensions the plugin -/// actually saw after invocation. Every plugin the factory builds -/// writes its observation to this shared Arc (latest wins). -fn manager_with_observing_factory() -> ( - Arc, - Arc>>, - Arc>>, -) { - let ledger = Arc::new(Mutex::new(Vec::new())); - let factory_calls = Arc::new(AtomicUsize::new(0)); - let observation_sink: Arc>> = - Arc::new(Mutex::new(None)); - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( - "recording", - Box::new(RecordingFactory { - ledger: Arc::clone(&ledger), - factory_calls: Arc::clone(&factory_calls), - observation_sink: Some(Arc::clone(&observation_sink)), - }), - ); - (mgr, ledger, observation_sink) -} - -// ===================================================================== -// Scenarios -// ===================================================================== - -/// Baseline: route's `authentication:` block dispatches the listed plugins, -/// in declared order, for `identity.resolve`. The ledger should -/// reflect the YAML order verbatim — proves the per-route binding + -/// preserved order story end-to-end. -#[tokio::test] -async fn route_identity_block_dispatches_in_declared_order() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - // Three identity plugins, all registered under `identity.resolve`. - // Route declares them in REVERSE priority order to prove that - // routing follows the `authentication:` declaration, not chain priority. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: jwt-a - kind: recording - hooks: [identity.resolve] - priority: 10 - - name: jwt-b - kind: recording - hooks: [identity.resolve] - priority: 20 - - name: jwt-c - kind: recording - hooks: [identity.resolve] - priority: 30 - -routes: - - tool: get_weather - authentication: - - jwt-c # priority 30 — would naturally run LAST in chain order - - jwt-a # priority 10 — would naturally run FIRST - - jwt-b # priority 20 -"#; - let parsed = config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_weather"), - None, - ) - .await; - - assert!( - result.continue_processing, - "pipeline should allow; violation = {:?}", - result.violation, - ); - - // Order matches the YAML's `authentication:` declaration, NOT plugin priority. - let firings = ledger.lock().unwrap().clone(); - assert_eq!(firings, vec!["jwt-c", "jwt-a", "jwt-b"]); -} - -/// `authentication:` is hook-specific. Plugins in the route's `plugins:` -/// block (which means "per-route overrides" in APL-driven routes -/// and "per-route binding" otherwise) must NOT fire for the -/// identity.resolve hook. This is the load-bearing test for -/// Option 1 — the design decision that `authentication:` is its own -/// dispatch list, independent of `plugins:`. -#[tokio::test] -async fn route_plugins_block_does_not_bind_identity_resolve() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - // The route declares `authentication:` with corp-jwt, and `plugins:` - // with rogue-jwt. rogue-jwt also registers under identity.resolve - // — but should NOT fire for the identity.resolve hook on this - // route because it's listed in `plugins:`, not `authentication:`. - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - - name: rogue-jwt - kind: recording - hooks: [identity.resolve] - -routes: - - tool: get_weather - authentication: - - corp-jwt - plugins: - - rogue-jwt -"#; - let parsed = config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_weather"), - None, - ) - .await; - assert!(result.continue_processing); - - // Only corp-jwt fired — rogue-jwt was in `plugins:`, not - // `authentication:`, so it's NOT bound for this hook on this route. - assert_eq!(ledger.lock().unwrap().clone(), vec!["corp-jwt"]); -} - -/// A route with no `authentication:` block produces zero identity -/// dispatches even when the entity_type / entity_name match. The -/// plugins ARE registered under identity.resolve, but no route -/// binds them, so the route-filter returns an empty entry list. -#[tokio::test] -async fn route_without_identity_block_dispatches_no_resolvers() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - -routes: - - tool: get_weather - # No identity: block. - plugins: - - corp-jwt -"#; - let parsed = config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_weather"), - None, - ) - .await; - assert!(result.continue_processing); - - // No identity plugins fired — `authentication:` was absent, so the - // route binds nothing for the identity.resolve hook even though - // corp-jwt is in `plugins:`. - assert!(ledger.lock().unwrap().is_empty()); -} - -/// A route declared for a different tool doesn't bind identity for -/// this request — proves scope/entity matching still works under -/// the new resolver path. -#[tokio::test] -async fn identity_route_filter_respects_entity_match() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - -routes: - - tool: get_compensation - authentication: - - corp-jwt -"#; - let parsed = config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - // Request for a DIFFERENT tool — corp-jwt should not fire. - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("unrelated_tool"), - None, - ) - .await; - assert!(result.continue_processing); - assert!( - ledger.lock().unwrap().is_empty(), - "identity must NOT fire for a non-matching route", - ); -} - -/// Per-step `config_override` produces a fresh plugin instance via -/// the existing `create_override_instance` pathway. The factory -/// call count goes up by one each time the route's identity step -/// is dispatched with an override — proves the wrapper around -/// `resolve_identity_plugins_for_route` correctly threads the -/// override through to `filter_entries_by_route`'s override branch. -#[tokio::test] -async fn per_step_config_override_produces_fresh_instance() { - let (mgr, _ledger, factory_calls) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - config: - audience: default-aud - -routes: - - tool: get_weather - authentication: - - name: corp-jwt - config: - audience: route-specific-aud -"#; - let parsed = config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - // Sanity: factory was called once for the base plugin during - // load_config. Track from here. - let base_calls = factory_calls.load(Ordering::SeqCst); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_weather"), - None, - ) - .await; - assert!(result.continue_processing); - - // One additional factory call for the override instance. - assert_eq!( - factory_calls.load(Ordering::SeqCst), - base_calls + 1, - "config_override should produce a new factory call", - ); -} - -/// End-to-end inheritance: global.identity contributes to -/// the dispatch lineup for routes that declare no identity block of -/// their own. Verifies the dispatch path picks up the global layer. -#[tokio::test] -async fn global_identity_inherited_when_route_has_no_block() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - -global: - authentication: - - corp-jwt - -routes: - - tool: get_weather -"#; - let parsed = cpex_core::config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_weather"), - None, - ) - .await; - assert!(result.continue_processing); - assert_eq!( - ledger.lock().unwrap().clone(), - vec!["corp-jwt"], - "global identity should fire when the route declares none", - ); -} - -/// Full stack — global + tag bundle + route — in declared order. -/// Proves the merge actually flows the layers through cpex-core's -/// dispatch in the order the resolver guarantees. -#[tokio::test] -async fn global_tag_route_identity_stack_dispatches_in_order() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - - name: workday-saml - kind: recording - hooks: [identity.resolve] - - name: agent-context - kind: recording - hooks: [identity.resolve] - -global: - authentication: - - corp-jwt - policies: - finance: - authentication: - - workday-saml - -routes: - - tool: get_compensation - meta: - tags: [finance] - authentication: - - agent-context -"#; - let parsed = cpex_core::config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_compensation"), - None, - ) - .await; - assert!(result.continue_processing); - - // Order: global → tag bundle → route. The ledger captures the - // actual dispatch order (preserves the resolver's stacking). - assert_eq!( - ledger.lock().unwrap().clone(), - vec!["corp-jwt", "workday-saml", "agent-context"], - ); -} - -/// Route opts out via `replace_inherited: true` — inherited layers -/// (global, tag bundles) are dropped. Only the route's steps run. -#[tokio::test] -async fn replace_inherited_drops_inherited_layers_end_to_end() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - - name: workday-saml - kind: recording - hooks: [identity.resolve] - - name: legacy-basic-auth - kind: recording - hooks: [identity.resolve] - -global: - authentication: - - corp-jwt - policies: - finance: - authentication: - - workday-saml - -routes: - - tool: legacy_endpoint - meta: - tags: [finance] - authentication: - replace_inherited: true - steps: - - legacy-basic-auth -"#; - let parsed = cpex_core::config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("legacy_endpoint"), - None, - ) - .await; - assert!(result.continue_processing); - - // Only the route's step ran — global and tag-bundle layers - // were dropped because `replace_inherited: true`. - assert_eq!(ledger.lock().unwrap().clone(), vec!["legacy-basic-auth"],); -} - -/// `replace_inherited: true` + `steps: []` — the explicit -/// "anonymous route, no identity" knob. Zero plugins fire even -/// though global identity is configured. -#[tokio::test] -async fn replace_inherited_with_empty_steps_yields_anonymous_route() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - -global: - authentication: - - corp-jwt - -routes: - - tool: public_endpoint - authentication: - replace_inherited: true - steps: [] -"#; - let parsed = cpex_core::config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("public_endpoint"), - None, - ) - .await; - assert!(result.continue_processing); - - assert!( - ledger.lock().unwrap().is_empty(), - "anonymous-route opt-out should suppress global identity", - ); -} - -/// Sanity that an empty Vec from the resolver (route has identity -/// but with `replace_inherited: true` and zero steps — the explicit -/// "opt out" knob) results in zero dispatches. -#[tokio::test] -async fn route_with_empty_identity_steps_dispatches_nothing() { - let (mgr, ledger, _) = manager_with_recording_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: corp-jwt - kind: recording - hooks: [identity.resolve] - -routes: - - tool: get_weather - authentication: - replace_inherited: true - steps: [] -"#; - let parsed = config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool("get_weather"), - None, - ) - .await; - assert!(result.continue_processing); - assert!(ledger.lock().unwrap().is_empty()); -} - -// --------------------------------------------------------------------- -// Capability gating on the identity dispatch path. -// -// Identity plugins go through cpex-core's executor like every other -// hook family — meaning `filter_extensions(&ext, &caps)` runs before -// each handler invoke and narrows what the plugin sees to its -// declared capabilities. These tests pin that behavior for the -// route-level identity dispatch path. -// -// Identity is unusual in that resolvers typically WRITE state (subject, -// chain) rather than read it — but they still need read capabilities -// for any extension-derived context they consult during resolution -// (e.g., a `read_meta`-gated resolver that branches on entity tags). -// --------------------------------------------------------------------- - -/// Build extensions seeded with subject + label so cap-gating tests -/// can verify what a resolver sees post-filter. -fn ext_for_tool_with_subject_and_label( - tool_name: &str, - subject_id: &str, - label: &str, -) -> Extensions { - use cpex_core::extensions::{SecurityExtension, SubjectExtension}; - let mut sec = SecurityExtension::default(); - sec.subject = Some(SubjectExtension { - id: Some(subject_id.to_string()), - ..Default::default() - }); - sec.add_label(label); - Extensions { - meta: Some(Arc::new(MetaExtension { - entity_type: Some("tool".to_string()), - entity_name: Some(tool_name.to_string()), - ..Default::default() - })), - security: Some(Arc::new(sec)), - ..Default::default() - } -} - -/// Identity resolver declaring `read_subject` sees `subject.id` in -/// Extensions but NOT `security.labels` — the executor strips the -/// labels slot because the plugin doesn't hold `read_labels`. -#[tokio::test] -async fn identity_plugin_with_read_subject_sees_subject_but_not_labels() { - let (mgr, _ledger, sink) = manager_with_observing_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: scoped-jwt - kind: recording - hooks: [identity.resolve] - capabilities: [read_subject] - -routes: - - tool: get_weather - authentication: - - scoped-jwt -"#; - let parsed = cpex_core::config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - // Extensions populated with BOTH subject (id=alice) AND a label - // (pii). The plugin should see subject only. - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool_with_subject_and_label("get_weather", "alice", "pii"), - None, - ) - .await; - assert!(result.continue_processing); - - let obs = sink - .lock() - .unwrap() - .clone() - .expect("plugin should have recorded its view"); - - assert_eq!( - obs.saw_subject_id.as_deref(), - Some("alice"), - "read_subject cap should expose subject.id", - ); - assert!( - obs.saw_labels.is_empty(), - "without read_labels, labels must be hidden — saw: {:?}", - obs.saw_labels, - ); -} - -/// Identity resolver with NO capabilities sees a fully-stripped -/// Extensions view. Negative case: confirms the executor's per-entry -/// filter actually hides slots when no cap is declared. -#[tokio::test] -async fn identity_plugin_without_caps_sees_stripped_extensions() { - let (mgr, _ledger, sink) = manager_with_observing_factory(); - - let yaml = r#" -plugin_settings: - routing_enabled: true -plugins: - - name: capless-jwt - kind: recording - hooks: [identity.resolve] - # capabilities: [] (omitted entirely; same effect) - -routes: - - tool: get_weather - authentication: - - capless-jwt -"#; - let parsed = cpex_core::config::parse_config(yaml).expect("parse"); - mgr.load_config(parsed).expect("load"); - mgr.initialize().await.unwrap(); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - build_payload("eyJ.fake.jwt"), - ext_for_tool_with_subject_and_label("get_weather", "alice", "pii"), - None, - ) - .await; - assert!(result.continue_processing); - - let obs = sink - .lock() - .unwrap() - .clone() - .expect("plugin should have recorded its view"); - - assert!( - obs.saw_subject_id.is_none(), - "without read_subject, subject must be hidden — saw: {:?}", - obs.saw_subject_id, - ); - assert!( - obs.saw_labels.is_empty(), - "without read_labels, labels must be hidden", - ); -} diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index 4d5ab479..b80b6f68 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -25,27 +25,15 @@ rust-version.workspace = true crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -cpex-core = { workspace = true } -# APL governance layer — bundled so Go/Python hosts can enable APL -# policies and route handlers via the `cpex_apl_install` FFI entry point. -apl-cpex = { workspace = true } -# The builtin extension set. `cpex_apl_install` delegates registration to -# cpex-builtins, whose `register_builtins` expands to explicit -# `register_factory` calls — so the factory symbols survive in the -# staticlib (no inventory/linkme link-section GC hazard). The feature set -# below is the exact bundle the default `.a` ships: four hook plugins, both -# bundled PDPs (cedar-direct and cel), and the Valkey session store. Valkey -# pulls a redis client + rustls TLS stack, so it adds materially to the `.a` -# size and to the musl cross-build surface. -cpex-builtins = { workspace = true, default-features = false, features = [ - "pii-scanner", - "audit-logger", - "identity-jwt", - "delegator-oauth", - "cedar-direct", - "cel", - "valkey", -] } +praxis-policy-core = { workspace = true } +# The CPEX facade: PPE with every stable builtin, the bundled hyper transport +# and the Vault secret provider, plus the host wiring the bindings share +# (`cpex::host`). `install_builtins` expands to explicit `register_factory` +# calls, so the factory symbols survive in the staticlib (no inventory/linkme +# link-section GC hazard). The Valkey session store pulls a redis client and a +# rustls TLS stack, so it adds materially to the `.a` size and to the musl +# cross-build surface. +cpex = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -55,11 +43,15 @@ tracing = { workspace = true } [features] # No opt-in features: the bundled set (including the Valkey session store) is -# fixed in the cpex-builtins dependency above. +# fixed by the cpex facade dependency above. default = [] [dev-dependencies] async-trait = { workspace = true } +# Mint test JWTs and their JWKS for the identity-jwt egress tests. The key is +# generated at test time, so no private key is checked in. +jsonwebtoken = { version = "11", default-features = false, features = ["aws_lc_rs"] } +aws-lc-rs = "1" [lints] workspace = true diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md index ef450bdb..6fb02820 100644 --- a/crates/cpex-ffi/RELEASE.md +++ b/crates/cpex-ffi/RELEASE.md @@ -8,12 +8,19 @@ 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. -> **APL bundled.** The published `.a` includes the APL (Attribute Policy -> Language) governance layer and its standard plugin/PDP factories -> (`validator/pii-scan`, `audit/logger`, `identity/jwt`, -> `delegator/oauth`, `cedar-direct`). Enable it on a manager via -> `cpex_apl_install` (Go: `PluginManager.EnableAPL()`) after -> `cpex_manager_new_default` and before `cpex_load_config`. +> **Builtins bundled.** The published `.a` includes the APL (Attribute +> Policy Language) governance layer and every non-experimental PPE builtin: +> plugin kinds `identity/jwt`, `identity/api-key`, `delegator/oauth` and +> `elicitation/ciba`; PDPs `cedar-direct`, `cel` and `opa`; the `valkey` +> session store; and the `env`, `file` and `vault` secret providers, plus +> an outbound HTTP transport. Enable them on an engine with +> `cpex_apl_install(engine, flags)` after `cpex_engine_new_default` and +> before `cpex_load_config`. Pass `CPEX_APL_ALLOW_PRIVATE_DESTINATIONS` +> (`1`) in `flags` to let builtins reach private or loopback addresses; +> they are blocked by default. Call `cpex_refresh_secrets` on a schedule +> to re-read secrets, and `cpex_secrets_last_success` to check their +> health. The authoritative list of bundled kinds and hook names is +> `crates/cpex-ffi/tests/fixtures/wire_compat.json`. ## What is published diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs index b8272cfb..899dee02 100644 --- a/crates/cpex-ffi/src/apl.rs +++ b/crates/cpex-ffi/src/apl.rs @@ -3,92 +3,147 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// APL (Authorization Policy Language) FFI wiring. +// Builtin, transport and secret-provider wiring for the FFI. // -// `cpex_apl_install` registers the bundled APL plugin factories and -// installs the APL config visitor on a manager so that a subsequent -// `cpex_load_config` walks `apl:` blocks and installs per-route handlers. +// `cpex_apl_install` registers PPE's bundled plugin, PDP and session-store +// factories, installs the APL config visitor, installs one outbound HTTP +// transport, and registers the secret providers (env, file, Vault) with +// that same transport, through `cpex::host::install_host_defaults`. A later +// `cpex_load_config` then walks `apl:` blocks and installs per-route +// handlers. // -// Registration is explicit (no inventory/ctor magic): it delegates to -// `cpex_builtins`, whose `register_builtins` / `builtin_pdps` expand to -// explicit `register_factory` / factory-construction calls, so the object -// code survives in `libcpex_ffi.a`. Which factories are bundled is the -// `cpex-builtins` feature set selected in this crate's Cargo.toml. +// Registration is explicit (no inventory/ctor magic): PPE's +// `install_builtins` expands to explicit `register_factory` calls, so the +// object code survives in `libcpex_ffi.a`. // -// Ordering: call AFTER `cpex_manager_new_default` and BEFORE +// Ordering: call AFTER `cpex_engine_new_default` and BEFORE // `cpex_load_config`. The config visitor must be registered before the -// config is loaded, and the one-shot `cpex_manager_new(yaml)` path loads -// during construction — so APL is only supported via the default-manager -// flow: +// config is loaded, and the one-shot `cpex_engine_new(yaml)` path loads +// during construction, so builtins are only available via the default +// engine flow: // -// cpex_manager_new_default -// → cpex_apl_install -// → cpex_load_config -// → cpex_initialize +// cpex_engine_new_default +// -> cpex_apl_install +// -> cpex_load_config +// -> cpex_initialize use std::os::raw::c_int; -use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::panic::{AssertUnwindSafe, catch_unwind}; -use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC}; +use crate::{CpexEngineInner, RC_INVALID_HANDLE, RC_INVALID_INPUT, RC_OK, RC_PANIC}; -/// Register the bundled APL plugin factories and install the APL config -/// visitor (in-process defaults: memory session store, default baseline -/// capabilities) on `mgr`. -/// -/// Bundled plugin factories (registered by `kind`, via cpex-builtins): -/// - `validator/pii-scan` → pii-scanner -/// - `audit/logger` → audit-logger -/// - `identity/jwt` → identity-jwt -/// - `delegator/oauth` → delegator-oauth +/// Where `cpex_apl_install` stands on one handle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum InstallState { + /// No install has run yet. + #[default] + NotInstalled, + /// An install completed. + Installed, + /// An install panicked part way. What it left behind is unknown, and a + /// retry could register a second APL visitor, so the state is final. + Failed, +} + +/// `cpex_apl_install` flag: let builtins reach private, loopback and +/// link-local destinations (for example a JWKS endpoint on localhost or +/// an in-cluster Vault). Off by default, which blocks them. This is a +/// host decision; no policy document can turn it on. +pub const CPEX_APL_ALLOW_PRIVATE_DESTINATIONS: u32 = 1; + +/// Every flag bit this build understands. Any other bit is rejected with +/// `RC_INVALID_INPUT`, so a host built against a newer flag set fails +/// loudly instead of silently losing a setting. +pub const CPEX_APL_KNOWN_FLAGS: u32 = CPEX_APL_ALLOW_PRIVATE_DESTINATIONS; + +/// Install the bundled builtins, the outbound HTTP transport, and the +/// secret providers on `handle`. /// -/// Bundled PDP factories (consulted for `global.apl.pdp[]` entries): -/// - `cedar-direct` → cedar-direct -/// - `cel` → cel +/// What it installs: +/// - plugin factories: `identity/jwt`, `identity/api-key`, +/// `delegator/oauth`, `elicitation/ciba` +/// - PDP factories for `global.pdp[]`: `cedar-direct`, `cel`, `opa` +/// - session store factory for `global.session_store`: `valkey` +/// (the in-process memory store stays the default) +/// - the APL config visitor +/// - one `HyperTransport` for builtins that declare `perform_http` +/// - secret providers `env`, `file` and `vault`, with Vault using the +/// same transport instance /// -/// Bundled session store factory (consulted for `global.apl.session_store`): -/// - `valkey` → Valkey-backed SessionStore +/// `flags` is a bit set of `CPEX_APL_*` values. With +/// `CPEX_APL_ALLOW_PRIVATE_DESTINATIONS` set, the transport may reach +/// private and loopback addresses; without it, such requests are refused +/// and builtins report their egress-denied codes. /// -/// The default in-process `MemorySessionStore` stays active unless the config -/// selects a `session_store`. +/// Idempotent per handle: once a call has succeeded, later calls do +/// nothing and return `RC_OK`, whatever their flags. A call that panicked +/// leaves the engine in an unknown state, so it stays failed: every later +/// call returns `RC_PANIC` and the host should discard the engine. The +/// transport and the secret providers are set-once on the engine; if the +/// host already installed either through Rust, the existing one is kept +/// and a warning is logged. /// -/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or -/// `RC_PANIC` if registration panicked (caught at the FFI boundary). +/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `handle` is null, +/// `RC_INVALID_INPUT` for an unknown flag bit, or `RC_PANIC` if this or +/// an earlier install on the handle panicked (caught at the FFI +/// boundary). /// /// # Safety -/// `mgr` must be a valid handle returned by `cpex_manager_new_default` -/// (or `cpex_manager_new`) and not yet shut down. -#[no_mangle] -pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int { - let inner = match mgr.as_ref() { - Some(m) => m, +/// `handle` must be a valid handle returned by `cpex_engine_new_default` +/// (or `cpex_engine_new`) and not yet shut down. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_apl_install(handle: *const CpexEngineInner, flags: u32) -> c_int { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { + Some(h) => h, None => return RC_INVALID_HANDLE, }; + if flags & !CPEX_APL_KNOWN_FLAGS != 0 { + tracing::error!( + "cpex_apl_install: unknown flag bits {:#x}", + flags & !CPEX_APL_KNOWN_FLAGS + ); + return RC_INVALID_INPUT; + } - let result = catch_unwind(AssertUnwindSafe(|| { - // Plugin factories — registered by `kind` string. Must happen - // before load_config so the manager can instantiate plugins whose - // YAML `kind:` matches. Delegated to cpex-builtins, whose enabled - // feature set determines the bundle. - cpex_builtins::register_builtins(&inner.manager); - - // APL config visitor + PDP / session-store factories. The factory - // sets are consulted for `global.apl.pdp[]` and - // `global.apl.session_store` entries; cedar-direct and cel are the - // bundled PDPs and the Valkey session-store factory is bundled too. - // Unless the config selects a `session_store`, the in-process - // MemorySessionStore default stays active. The visitor keeps a - // Weak (see CpexManagerInner) that upgrades during - // load_config_yaml. - let mut opts = apl_cpex::AplOptions::in_process(); - opts.pdp_factories = cpex_builtins::builtin_pdps(); - opts.session_store_factories = cpex_builtins::builtin_session_store_factories(); + // Held for the whole install so a concurrent second call waits and + // then sees the outcome, rather than racing ahead to load a config + // before the factories exist. The install runs under catch_unwind, so + // the lock is not poisoned by it; recover the state either way. + let mut state = match inner.install_state.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + match *state { + InstallState::Installed => return RC_OK, + InstallState::Failed => { + tracing::error!( + "cpex_apl_install: an earlier install on this handle panicked; discard the engine" + ); + return RC_PANIC; + }, + InstallState::NotInstalled => {}, + } - apl_cpex::register_apl(&inner.manager, opts); + let allow_private = flags & CPEX_APL_ALLOW_PRIVATE_DESTINATIONS != 0; + let result = catch_unwind(AssertUnwindSafe(|| { + #[cfg(test)] + if inner + .fail_install + .load(std::sync::atomic::Ordering::Relaxed) + { + panic!("injected install failure"); + } + cpex::host::install_host_defaults(&inner.engine, allow_private); })); match result { - Ok(()) => RC_OK, + Ok(()) => { + *state = InstallState::Installed; + RC_OK + }, Err(_panic) => { + *state = InstallState::Failed; tracing::error!("cpex_apl_install: panic caught at FFI boundary"); RC_PANIC }, diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index 4d4d765a..902429ba 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -9,24 +9,31 @@ // and other languages can call. Payloads and extensions cross the boundary // as MessagePack bytes. ContextTable and BackgroundTasks are opaque handles. // -// Each PluginManager owns its own tokio runtime so async plugin execution -// works from synchronous cgo calls. +// Every engine runs its async work on one process-wide tokio runtime, so +// async plugin execution works from synchronous cgo calls. use std::os::raw::{c_char, c_int}; -use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::ptr; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; -use cpex_core::context::PluginContextTable; -use cpex_core::executor::BackgroundTasks; -use cpex_core::extensions::Extensions; -use cpex_core::hooks::payload::PluginPayload; -use cpex_core::identity::IdentityPayload; -use cpex_core::manager::PluginManager; +use praxis_policy_core::context::PluginContextTable; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::executor::BackgroundTasks; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::hooks::payload::PluginPayload; +use praxis_policy_core::identity::IdentityPayload; -// APL governance wiring — the `cpex_apl_install` extern "C" entry point. +// Builtin, HTTP transport and secret-provider wiring: the +// `cpex_apl_install` extern "C" entry point. mod apl; +// Secret refresh and health: `cpex_refresh_secrets` and +// `cpex_secrets_last_success`. +mod secrets; + +pub use apl::{CPEX_APL_ALLOW_PRIVATE_DESTINATIONS, CPEX_APL_KNOWN_FLAGS, cpex_apl_install}; +pub use secrets::{cpex_refresh_secrets, cpex_secrets_last_success}; // FFI functions return c_int. 0 means success; negative codes classify // failures so the Go (or other-language) caller can produce a typed @@ -34,11 +41,11 @@ mod apl; // are stable wire ABI — additions go at the end with a fresh value; // don't renumber. // -// Mapped on the Go side in `go/cpex/manager.go::errorFromRC`. +// Mapped on the Go side in `go/cpex/errors.go::errorFromRC`. /// Operation succeeded. pub const RC_OK: c_int = 0; -/// Manager handle is null or shut down. +/// Engine handle is null or shut down. pub const RC_INVALID_HANDLE: c_int = -1; /// Caller-supplied input is malformed (bad UTF-8, null pointer where /// data was required, oversized buffer, unknown payload type). @@ -56,7 +63,8 @@ pub const RC_SERIALIZE_ERROR: c_int = -5; /// Wall-clock timeout exceeded inside `run_safely` — plugin likely /// CPU-bound or blocking the OS thread without yielding. pub const RC_TIMEOUT: c_int = -6; -/// Plugin panicked; caught by `catch_unwind` at the FFI boundary. +/// FFI-layer panic caught by `catch_unwind`; plugin panics are denied +/// results with violation code `plugin_panic`. pub const RC_PANIC: c_int = -7; // The FFI ABI version is an integer that identifies the C-surface @@ -86,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 = 2; +pub const FFI_ABI_VERSION: u32 = 3; /// Returns the FFI ABI version this `libcpex_ffi` was built with. /// Language bindings call this at `init` and panic on mismatch @@ -94,7 +102,7 @@ pub const FFI_ABI_VERSION: u32 = 2; /// /// Pure const access — no allocation, no runtime, no panics. Safe to /// call from anywhere including signal handlers. -#[no_mangle] +#[unsafe(no_mangle)] pub extern "C" fn cpex_ffi_abi_version() -> u32 { FFI_ABI_VERSION } @@ -106,10 +114,10 @@ pub extern "C" fn cpex_ffi_abi_version() -> u32 { /// usual case never hits this bound. const FFI_WALL_CLOCK_TIMEOUT: Duration = Duration::from_secs(60); -// One process-singleton runtime serves every manager rather than each -// `cpex_manager_new` building its own. With many managers (multi-tenant +// One process-singleton runtime serves every engine rather than each +// `cpex_engine_new` building its own. With many engines (multi-tenant // hosts that create one per request, dynamic plugin reload, etc.) the -// per-manager model exploded thread count: 100 managers × num_cpus +// per-engine model exploded thread count: 100 engines × num_cpus // workers each = hundreds of OS threads (and ~2MB stack apiece). // // Worker thread count precedence (highest first): @@ -134,7 +142,14 @@ const ENV_WORKER_THREADS: &str = "CPEX_FFI_WORKER_THREADS"; /// Extracted from `shared_runtime` so it's unit-testable without /// touching the global `OnceLock`. fn worker_threads_from_env() -> Option { - let raw = std::env::var(ENV_WORKER_THREADS).ok()?; + parse_worker_threads(&std::env::var(ENV_WORKER_THREADS).ok()?) +} + +/// Parse a raw `CPEX_FFI_WORKER_THREADS` value. Split from +/// `worker_threads_from_env` so tests can cover the parsing without +/// mutating the process environment, which is unsound while other test +/// threads may read it. +fn parse_worker_threads(raw: &str) -> Option { match raw.parse::() { Ok(n) if n > 0 => Some(n), Ok(_) => { @@ -184,14 +199,14 @@ fn shared_runtime() -> &'static tokio::runtime::Runtime { /// Configure the shared tokio runtime's worker thread count. /// -/// Must be called *before* any `cpex_manager_new` / `cpex_manager_new_default` -/// call — once a manager has been created the runtime is fixed for +/// Must be called *before* any `cpex_engine_new` / `cpex_engine_new_default` +/// call: once an engine has been created the runtime is fixed for /// the process lifetime. Returns `RC_OK` on success or /// `RC_INVALID_INPUT` if the runtime has already been initialized /// (or if `worker_threads` is non-positive). /// /// Use case: multi-tenant hosts that want to bound total worker -/// threads regardless of how many `PluginManager`s are alive. +/// threads regardless of how many `PolicyEngine`s are alive. /// /// Precedence: this FFI call beats `CPEX_FFI_WORKER_THREADS` (the env /// var is read only on lazy init via `shared_runtime()`; an explicit @@ -200,10 +215,10 @@ fn shared_runtime() -> &'static tokio::runtime::Runtime { /// override. /// /// # Safety -/// Safe to call from a single thread before any manager creation. -/// Calling after a manager exists is well-defined (returns +/// Safe to call from a single thread before any engine creation. +/// Calling after an engine exists is well-defined (returns /// `RC_INVALID_INPUT`) but does not change the active runtime. -#[no_mangle] +#[unsafe(no_mangle)] pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { if worker_threads <= 0 { return RC_INVALID_INPUT; @@ -223,10 +238,10 @@ pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { Ok(()) => RC_OK, Err(_) => { // Runtime already initialized — caller should have set - // this before any manager creation. + // this before any engine creation. tracing::warn!( "cpex_configure_runtime: runtime already initialized; \ - configuration ignored. Call before any cpex_manager_new.", + configuration ignored. Call before any cpex_engine_new.", ); RC_INVALID_INPUT }, @@ -309,7 +324,8 @@ pub const PAYLOAD_CMF_MESSAGE: u8 = 1; /// `IdentityPayload` — the input/output state of the `identity.resolve` /// hook. Lets non-Rust hosts (the Go bindings) drive identity resolution /// over the FFI: send the request headers in, read the resolved -/// `subject` / `client` / `raw_credentials` back out. Without this an +/// `subject` / `client` / `raw_credentials` back out (the request +/// headers are not echoed). Without this an /// FFI host can't run `identity.resolve` (its payload is neither a /// generic value nor a CMF message), so per-route APL gates that read /// `subject.*` never see a principal. @@ -325,7 +341,7 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result { - let msg: cpex_core::cmf::MessagePayload = rmp_serde::from_slice(bytes) + let msg: praxis_policy_core::cmf::MessagePayload = rmp_serde::from_slice(bytes) .map_err(|e| format!("CMF payload deserialize failed: {}", e))?; Ok(Box::new(msg)) }, @@ -338,6 +354,52 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result Result<(), c_int> { + match cpex::host::removed_hook_replacement(name) { + Some(replacement) => { + tracing::error!("{}: hook '{}' was removed; use '{}'", op, name, replacement); + Err(RC_INVALID_INPUT) + }, + None => Ok(()), + } +} + +/// Refuse an `identity.resolve` input that already carries resolver +/// output (subject, client, claims and so on). Those fields are what the +/// resolvers fill in; a caller that pre-fills them could forge the +/// resolved identity. +fn reject_resolved_identity(idp: &IdentityPayload, op: &str) -> Result<(), c_int> { + let set = cpex::host::resolved_identity_fields(idp); + if set.is_empty() { + return Ok(()); + } + tracing::error!( + "{}: identity.resolve input must not set resolver output fields: {}", + op, + set.join(", ") + ); + Err(RC_INVALID_INPUT) +} + +/// [`reject_resolved_identity`] for a payload about to be dispatched on +/// `hook_name`: applies only to an `IdentityPayload` on `identity.resolve`. +fn reject_resolved_identity_payload( + hook_name: &str, + payload: &dyn PluginPayload, + op: &str, +) -> Result<(), c_int> { + if hook_name != praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE { + return Ok(()); + } + match payload.as_any().downcast_ref::() { + Some(idp) => reject_resolved_identity(idp, op), + None => Ok(()), + } +} + /// Serialize a modified payload back to MessagePack bytes. /// Returns the payload type ID alongside the bytes so the caller /// knows how to deserialize on the other side. @@ -353,7 +415,7 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin // Try CMF MessagePayload first (most common) if let Some(mp) = payload .as_any() - .downcast_ref::() + .downcast_ref::() { return rmp_serde::to_vec_named(mp) .map(|b| (PAYLOAD_CMF_MESSAGE, b)) @@ -365,26 +427,51 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin .map(|b| (PAYLOAD_GENERIC, b)) .map_err(|e| format!("generic payload serialize failed: {e}")); } - // Try IdentityPayload — carries the resolved subject/client/raw - // credentials back to an FFI host after `identity.resolve`. + // Try IdentityPayload: carries the resolved subject, client and claims + // back to an FFI host after `identity.resolve`. It comes back without + // its `headers`: they are the request headers the caller passed in, + // including the credential header (`Authorization` and the like), so + // echoing them would hand the inbound token back to anything that logs + // or forwards the result. The raw token field is skip-serialized by PPE. if let Some(idp) = payload.as_any().downcast_ref::() { - return rmp_serde::to_vec_named(idp) + let value = cpex::host::identity_result_value(idp) + .map_err(|e| format!("identity payload serialize failed: {e}"))?; + return rmp_serde::to_vec_named(&value) .map(|b| (PAYLOAD_IDENTITY, b)) .map_err(|e| format!("identity payload serialize failed: {e}")); } Err("unknown payload type, cannot serialize across FFI".to_string()) } -/// Opaque handle to a PluginManager. +/// Opaque handle to a PolicyEngine. /// -/// All managers share the process-singleton runtime returned by -/// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. -pub struct CpexManagerInner { - /// Held as `Arc` so the APL config visitor — registered via - /// `cpex_apl_install` — can keep a `Weak` that upgrades - /// during `load_config_yaml`. See `apl::cpex_apl_install` and - /// `apl_cpex::register_apl`. - pub manager: Arc, +/// All engines share the process-singleton runtime returned by +/// `shared_runtime()`; see the `SHARED_RUNTIME` doc-comment for why. +pub struct CpexEngineInner { + /// Held as `Arc` so the APL config visitor registered by + /// `cpex_apl_install` can keep a `Weak` that upgrades + /// during `load_config_yaml`. + pub engine: Arc, + /// Whether `cpex_apl_install` ran on this handle and how it ended. A + /// mutex rather than an atomic so a concurrent second call waits for + /// the first to finish instead of returning before the builtins exist. + install_state: Mutex, + /// Test-only: makes the next `cpex_apl_install` panic inside its + /// catch_unwind, to reach the failed state. + #[cfg(test)] + fail_install: std::sync::atomic::AtomicBool, +} + +impl CpexEngineInner { + /// Wrap an engine in a fresh handle with nothing installed. + pub fn new(engine: Arc) -> Self { + Self { + engine, + install_state: Mutex::new(apl::InstallState::NotInstalled), + #[cfg(test)] + fail_install: std::sync::atomic::AtomicBool::new(false), + } + } } /// Opaque handle to a ContextTable (Rust-owned, not serialized). @@ -401,7 +488,9 @@ unsafe fn c_str_to_slice<'a>(ptr: *const c_char, len: c_int) -> Option<&'a str> if ptr.is_null() || len <= 0 { return None; } - let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); + // SAFETY: `ptr` is non-null and `len` is positive (checked above); the + // caller guarantees `ptr` points to `len` readable bytes that outlive `'a`. + let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; std::str::from_utf8(bytes).ok() } @@ -409,7 +498,9 @@ unsafe fn c_bytes_to_slice<'a>(ptr: *const u8, len: c_int) -> Option<&'a [u8]> { if ptr.is_null() || len <= 0 { return None; } - Some(std::slice::from_raw_parts(ptr, len as usize)) + // SAFETY: `ptr` is non-null and `len` is positive (checked above); the + // caller guarantees `ptr` points to `len` readable bytes that outlive `'a`. + Some(unsafe { std::slice::from_raw_parts(ptr, len as usize) }) } /// Allocate a byte buffer and return it to the caller. @@ -445,84 +536,80 @@ fn alloc_bytes(data: &[u8]) -> (*mut u8, c_int) { } } -/// Create a new PluginManager from a YAML config string. +/// Create a new PolicyEngine from a YAML config string. /// -/// Returns an opaque handle. The manager owns a tokio runtime for -/// async plugin execution. Returns NULL on failure. +/// Returns an opaque handle, or NULL on failure. No builtins and no HTTP +/// transport are installed on this path: factories must be registered +/// before the config loads, which this one-shot constructor cannot +/// offer. Use `cpex_engine_new_default`, then `cpex_apl_install`, then +/// `cpex_load_config` to get the bundled builtins. /// /// # Safety /// `config_yaml` must be a valid pointer to `config_len` bytes of UTF-8. -#[no_mangle] -pub unsafe extern "C" fn cpex_manager_new( +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_engine_new( config_yaml: *const c_char, config_len: c_int, -) -> *mut CpexManagerInner { - let yaml = match c_str_to_slice(config_yaml, config_len) { +) -> *mut CpexEngineInner { + // SAFETY: the caller guarantees `config_yaml` is NULL or points to `config_len` readable bytes. + let yaml = match unsafe { c_str_to_slice(config_yaml, config_len) } { Some(s) => s, None => return ptr::null_mut(), }; - let cpex_config = match cpex_core::config::parse_config(yaml) { - Ok(c) => c, - Err(e) => { - tracing::error!("cpex_manager_new: config parse failed: {}", e); - return ptr::null_mut(); - }, - }; - // Touch the shared runtime so any later cpex_configure_runtime // call returns RC_INVALID_INPUT — communicates "you missed the // window" to the operator, instead of letting the configure call // silently no-op. let _ = shared_runtime(); - let manager = Arc::new(PluginManager::default()); + let engine = Arc::new(PolicyEngine::default()); - // Load config — factories must be registered separately via cpex_register_factory. - // Note: this one-shot path uses `load_config` (no visitor walk), so APL is - // NOT wired here. APL requires the cpex_manager_new_default → - // cpex_apl_install → cpex_load_config flow. - if let Err(e) = manager.load_config(cpex_config) { - tracing::error!("cpex_manager_new: load_config failed: {}", e); + // The YAML entry point rejects APL-owned keys when no visitor is installed. + // Loading an already-parsed config here would silently discard those rules. + if let Err(e) = engine.load_config_yaml(yaml) { + tracing::error!("cpex_engine_new: load_config_yaml failed: {}", e); return ptr::null_mut(); } - Box::into_raw(Box::new(CpexManagerInner { manager })) + Box::into_raw(Box::new(CpexEngineInner::new(engine))) } -/// Create a new PluginManager with default config (no YAML). +/// Create a new PolicyEngine with default config (no YAML). /// /// Useful when registering plugins programmatically. -#[no_mangle] -pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { +#[unsafe(no_mangle)] +pub extern "C" fn cpex_engine_new_default() -> *mut CpexEngineInner { let _ = shared_runtime(); - let manager = Arc::new(PluginManager::default()); - Box::into_raw(Box::new(CpexManagerInner { manager })) + let engine = Arc::new(PolicyEngine::default()); + Box::into_raw(Box::new(CpexEngineInner::new(engine))) } -/// Load a YAML config into an existing manager. +/// Load a YAML config into an existing engine. /// /// Factories must be registered before calling this function. /// Returns 0 on success, -1 on failure. /// /// # Safety -/// `mgr` must be a valid handle. `config_yaml` must be valid UTF-8. -/// `mgr` is `*const` — `PluginManager::load_config` takes `&self` after +/// `handle` must be a valid handle. `config_yaml` must be valid UTF-8. +/// `handle` is `*const`: `PolicyEngine::load_config` takes `&self` after /// the ArcSwap snapshot refactor (no exclusive access needed). Two /// callers loading config concurrently is safe; the snapshot swap is /// atomic copy-on-write, so they see consistent state per call. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_load_config( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, config_yaml: *const c_char, config_len: c_int, ) -> c_int { - let inner = match mgr.as_ref() { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => return RC_INVALID_HANDLE, }; - let yaml = match c_str_to_slice(config_yaml, config_len) { + // SAFETY: the caller guarantees `config_yaml` is NULL or points to `config_len` readable bytes. + let yaml = match unsafe { c_str_to_slice(config_yaml, config_len) } { Some(s) => s, None => return RC_INVALID_INPUT, }; @@ -530,11 +617,11 @@ pub unsafe extern "C" fn cpex_load_config( // Validate first (duplicate plugin names, route shape) — preserves the // RC_PARSE_ERROR contract. We discard the parsed value and hand the raw // YAML to `load_config_yaml`, which re-parses into both a typed - // CpexConfig and a raw serde_yaml::Value so registered config visitors + // PolicyConfig and a raw serde_yaml::Value so registered config visitors // (e.g. the APL visitor installed by cpex_apl_install) can walk the // `apl:` blocks and install per-route handlers. Plain `load_config` // does NOT run that visitor walk. - if let Err(e) = cpex_core::config::parse_config(yaml) { + if let Err(e) = praxis_policy_core::config::parse_config(yaml) { tracing::error!("cpex_load_config: config parse failed: {}", e); return RC_PARSE_ERROR; } @@ -542,7 +629,7 @@ pub unsafe extern "C" fn cpex_load_config( // load_config_yaml is sync (no .await), but we still wrap in catch_unwind // so a panic in serde / config validation / a visitor doesn't unwind // across FFI. - let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config_yaml(yaml))); + let load_result = catch_unwind(AssertUnwindSafe(|| inner.engine.load_config_yaml(yaml))); match load_result { Ok(Ok(())) => RC_OK, Ok(Err(e)) => { @@ -561,16 +648,17 @@ pub unsafe extern "C" fn cpex_load_config( /// Returns 0 on success, -1 on failure (including timeout / panic). /// /// # Safety -/// `mgr` must be a valid handle from `cpex_manager_new`. -/// `mgr` is `*const` — `PluginManager::initialize` takes `&self`. -#[no_mangle] -pub unsafe extern "C" fn cpex_initialize(mgr: *const CpexManagerInner) -> c_int { - let inner = match mgr.as_ref() { +/// `handle` must be a valid handle from `cpex_engine_new`. +/// `handle` is `*const`: `PolicyEngine::initialize` takes `&self`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_initialize(handle: *const CpexEngineInner) -> c_int { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => return RC_INVALID_HANDLE, }; - match run_safely(inner.manager.initialize(), "cpex_initialize") { + match run_safely(inner.engine.initialize(), "cpex_initialize") { SafeRun::Ok(Ok(())) => RC_OK, SafeRun::Ok(Err(e)) => { tracing::error!("cpex_initialize: {}", e); @@ -580,26 +668,28 @@ pub unsafe extern "C" fn cpex_initialize(mgr: *const CpexManagerInner) -> c_int } } -/// Shutdown all plugins and free the manager. +/// Shutdown all plugins and free the engine. /// -/// `mgr` stays `*mut` here because we consume the Box (this is the one +/// `handle` stays `*mut` here because we consume the Box (this is the one /// place we genuinely take exclusive ownership — destroying the /// allocation). All other entry points use `*const`. /// /// # Safety -/// `mgr` must be a valid handle from `cpex_manager_new`. After this +/// `handle` must be a valid handle from `cpex_engine_new`. After this /// call, the handle is invalid and must not be used. -#[no_mangle] -pub unsafe extern "C" fn cpex_shutdown(mgr: *mut CpexManagerInner) { - if mgr.is_null() { +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_shutdown(handle: *mut CpexEngineInner) { + if handle.is_null() { return; } - let inner = Box::from_raw(mgr); + // SAFETY: `handle` is non-null and, per the caller contract, came from + // `Box::into_raw` in a constructor and has not been freed. + let inner = unsafe { Box::from_raw(handle) }; // Wrap shutdown in catch_unwind + timeout so a misbehaving plugin // can't hang teardown forever or unwind across the FFI boundary. // We don't have a return value here — `inner` is dropped at function - // end either way, freeing the manager and runtime. - let _ = run_safely(inner.manager.shutdown(), "cpex_shutdown"); + // end either way, freeing the engine and runtime. + let _ = run_safely(inner.engine.shutdown(), "cpex_shutdown"); } /// Check if any plugins are registered for a hook name. @@ -607,22 +697,24 @@ pub unsafe extern "C" fn cpex_shutdown(mgr: *mut CpexManagerInner) { /// Returns 1 (true) or 0 (false). No serialization — just a hash lookup. /// /// # Safety -/// `mgr` must be valid. `hook_name` must point to `hook_len` bytes of UTF-8. -#[no_mangle] +/// `handle` must be valid. `hook_name` must point to `hook_len` bytes of UTF-8. +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_has_hooks_for( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, hook_name: *const c_char, hook_len: c_int, ) -> c_int { - let inner = match mgr.as_ref() { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => return 0, }; - let name = match c_str_to_slice(hook_name, hook_len) { + // SAFETY: the caller guarantees `hook_name` is NULL or points to `hook_len` readable bytes. + let name = match unsafe { c_str_to_slice(hook_name, hook_len) } { Some(s) => s, None => return 0, }; - if inner.manager.has_hooks_for(name) { + if inner.engine.has_hooks_for(name) { 1 } else { 0 @@ -634,26 +726,28 @@ pub unsafe extern "C" fn cpex_has_hooks_for( /// No serialization — returns an integer directly. /// /// # Safety -/// `mgr` must be valid. -#[no_mangle] -pub unsafe extern "C" fn cpex_plugin_count(mgr: *const CpexManagerInner) -> c_int { - match mgr.as_ref() { - Some(m) => m.manager.plugin_count() as c_int, +/// `handle` must be valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_plugin_count(handle: *const CpexEngineInner) -> c_int { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + match unsafe { handle.as_ref() } { + Some(m) => m.engine.plugin_count() as c_int, None => 0, } } -/// Whether the manager has been initialized (i.e., `cpex_initialize` +/// Whether the engine has been initialized (i.e., `cpex_initialize` /// returned successfully and `cpex_shutdown` has not been called). /// -/// Returns 1 if initialized, 0 otherwise (including null mgr). +/// Returns 1 if initialized, 0 otherwise (including null handle). /// /// # Safety -/// `mgr` must be valid or NULL. -#[no_mangle] -pub unsafe extern "C" fn cpex_is_initialized(mgr: *const CpexManagerInner) -> c_int { - match mgr.as_ref() { - Some(m) if m.manager.is_initialized() => 1, +/// `handle` must be valid or NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_is_initialized(handle: *const CpexEngineInner) -> c_int { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + match unsafe { handle.as_ref() } { + Some(m) if m.engine.is_initialized() => 1, _ => 0, } } @@ -666,32 +760,44 @@ pub unsafe extern "C" fn cpex_is_initialized(mgr: *const CpexManagerInner) -> c_ /// `*names_msgpack_out` / `*names_len_out`. /// /// # Safety -/// `mgr` must be valid. Output pointers must be writable. -#[no_mangle] +/// `handle` must be valid. Output pointers must be writable. +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_plugin_names( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, names_msgpack_out: *mut *mut u8, names_len_out: *mut c_int, ) -> c_int { - let inner = match mgr.as_ref() { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => return RC_INVALID_HANDLE, }; - let names = inner.manager.plugin_names(); + let names = inner.engine.plugin_names(); let bytes = match rmp_serde::to_vec_named(&names) { Ok(b) => b, Err(_) => return RC_SERIALIZE_ERROR, }; let (ptr, len) = alloc_bytes(&bytes); - *names_msgpack_out = ptr; - *names_len_out = len; + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *names_msgpack_out = ptr; + *names_len_out = len; + } RC_OK } /// Invoke a hook by name. /// /// Payload and extensions are passed as MessagePack bytes. +/// +/// A hook name removed in 0.3 (`tool_pre_invoke`, `identity_resolve` and +/// the rest of `cpex::host::REMOVED_HOOKS`) returns `RC_INVALID_INPUT` and +/// logs its replacement: it would reach no handler, so dispatching it would +/// allow with no policy evaluated. An `identity.resolve` call with a +/// `PAYLOAD_IDENTITY` payload that sets any resolver output field +/// (`subject`, `client`, `caller_workload`, `delegation`, `raw_credentials`, +/// `resolved_at`, `raw_claims`) also returns `RC_INVALID_INPUT`. /// ContextTable is an opaque handle (NULL for first invocation). /// Returns MessagePack-encoded PipelineResult + opaque handles for /// context table and background tasks. @@ -722,14 +828,14 @@ pub unsafe extern "C" fn cpex_plugin_names( /// /// # Safety /// All pointer parameters must be valid or NULL where documented. -/// `mgr` is `*const` — `PluginManager::invoke_by_name` takes `&self` +/// `handle` is `*const`: `PolicyEngine::invoke_by_name` takes `&self` /// after the ArcSwap snapshot refactor. The previous `*mut` + `as_mut()` /// shape produced aliased `&mut` references when two goroutines called /// this concurrently — UB regardless of what the called code did. The /// `&self` API plus `*const` here is sound for parallel dispatch. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_invoke( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, hook_name: *const c_char, hook_len: c_int, payload_type: u8, @@ -748,10 +854,13 @@ pub unsafe extern "C" fn cpex_invoke( // out pointer is null/zero, so a downstream attempt to dereference // produces a clean null-deref crash rather than reading uninit // stack memory. The success path overwrites these at the end. - *result_msgpack_out = std::ptr::null_mut(); - *result_len_out = 0; - *context_table_out = std::ptr::null_mut(); - *bg_handle_out = std::ptr::null_mut(); + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *result_msgpack_out = std::ptr::null_mut(); + *result_len_out = 0; + *context_table_out = std::ptr::null_mut(); + *bg_handle_out = std::ptr::null_mut(); + } // Take ownership of the input context_table *immediately*, before // any validation that could return an error code. From this point @@ -774,24 +883,32 @@ pub unsafe extern "C" fn cpex_invoke( // into Some(...), the table value lives until invoke_by_name // either uses it or it's dropped on a Future-cancellation // path (RC_TIMEOUT). Either way the Box is gone. - let ct = Box::from_raw(context_table); + // SAFETY: `context_table` is non-null and, per the caller contract, + // a table handle from a prior invoke that has not been freed. + let ct = unsafe { Box::from_raw(context_table) }; Some(ct.table) }; - // Validate manager handle. `input_ctx_table` already owns the + // Validate engine handle. `input_ctx_table` already owns the // input data — if we return here, it drops cleanly. - let inner = match mgr.as_ref() { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => return RC_INVALID_HANDLE, }; - let name = match c_str_to_slice(hook_name, hook_len) { + // SAFETY: the caller guarantees `hook_name` is NULL or points to `hook_len` readable bytes. + let name = match unsafe { c_str_to_slice(hook_name, hook_len) } { Some(s) => s, None => return RC_INVALID_INPUT, }; + if let Err(rc) = reject_removed_hook(name, "cpex_invoke") { + return rc; + } // Deserialize payload using the type registry - let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + // SAFETY: the caller guarantees `payload_msgpack` is NULL or points to `payload_len` readable bytes. + let payload_bytes = match unsafe { c_bytes_to_slice(payload_msgpack, payload_len) } { Some(b) => b, None => return RC_INVALID_INPUT, }; @@ -803,10 +920,14 @@ pub unsafe extern "C" fn cpex_invoke( return RC_PARSE_ERROR; }, }; + if let Err(rc) = reject_resolved_identity_payload(name, payload.as_ref(), "cpex_invoke") { + return rc; + } // Deserialize extensions from MessagePack let extensions: Extensions = if extensions_len > 0 { - let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + // SAFETY: the caller guarantees `extensions_msgpack` is NULL or points to `extensions_len` readable bytes. + let ext_bytes = match unsafe { c_bytes_to_slice(extensions_msgpack, extensions_len) } { Some(b) => b, None => return RC_INVALID_INPUT, }; @@ -824,7 +945,7 @@ pub unsafe extern "C" fn cpex_invoke( // Invoke the hook with wall-clock timeout + panic catch. let (mut result, bg) = match run_safely( inner - .manager + .engine .invoke_by_name(name, payload, extensions, input_ctx_table), "cpex_invoke", ) { @@ -848,13 +969,17 @@ pub unsafe extern "C" fn cpex_invoke( Ok((t, b)) => (t, Some(b)), Err(e) => { tracing::warn!("cpex_invoke: dropped modified payload — {}", e); - result.errors.push(cpex_core::error::PluginErrorRecord { - plugin_name: "".to_string(), - message: format!("modified payload could not be serialized across FFI: {e}"), - code: Some("ffi_serialize_error".to_string()), - details: std::collections::HashMap::new(), - proto_error_code: None, - }); + result + .errors + .push(praxis_policy_core::error::PluginErrorRecord { + plugin_name: "".to_string(), + message: format!( + "modified payload could not be serialized across FFI: {e}" + ), + code: Some("ffi_serialize_error".to_string()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }); (payload_type, None) }, }, @@ -888,14 +1013,17 @@ pub unsafe extern "C" fn cpex_invoke( }; let (ptr, len) = alloc_bytes(&result_bytes); - *result_msgpack_out = ptr; - *result_len_out = len; + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *result_msgpack_out = ptr; + *result_len_out = len; - *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { - table: result.context_table, - })); + *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { + table: result.context_table, + })); - *bg_handle_out = Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: bg })); + *bg_handle_out = Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: bg })); + } RC_OK } @@ -917,15 +1045,17 @@ pub unsafe extern "C" fn cpex_invoke( /// `identity.resolve` hook is registered, this degrades to a plain hook /// invoke against the supplied extensions. If `identity.resolve` denies /// (e.g. bad token), that denial is returned and the hook does not run. +/// An identity payload that sets any resolver output field, or a removed +/// hook name, returns `RC_INVALID_INPUT` as in [`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 /// and populated on `RC_OK`. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_invoke_resolved( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, identity_msgpack: *const u8, identity_len: c_int, hook_name: *const c_char, @@ -941,30 +1071,41 @@ pub unsafe extern "C" fn cpex_invoke_resolved( context_table_out: *mut *mut CpexContextTableInner, bg_handle_out: *mut *mut CpexBackgroundTasksInner, ) -> c_int { - *result_msgpack_out = std::ptr::null_mut(); - *result_len_out = 0; - *context_table_out = std::ptr::null_mut(); - *bg_handle_out = std::ptr::null_mut(); + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *result_msgpack_out = std::ptr::null_mut(); + *result_len_out = 0; + *context_table_out = std::ptr::null_mut(); + *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() { None } else { - Some(Box::from_raw(context_table).table) + // SAFETY: `context_table` is non-null and, per the caller contract, + // a table handle from a prior invoke that has not been freed. + Some(unsafe { Box::from_raw(context_table) }.table) }; - let inner = match mgr.as_ref() { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => return RC_INVALID_HANDLE, }; - let name = match c_str_to_slice(hook_name, hook_len) { + // SAFETY: the caller guarantees `hook_name` is NULL or points to `hook_len` readable bytes. + let name = match unsafe { c_str_to_slice(hook_name, hook_len) } { Some(s) => s, None => return RC_INVALID_INPUT, }; + if let Err(rc) = reject_removed_hook(name, "cpex_invoke_resolved") { + return rc; + } - let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + // SAFETY: the caller guarantees `payload_msgpack` is NULL or points to `payload_len` readable bytes. + let payload_bytes = match unsafe { c_bytes_to_slice(payload_msgpack, payload_len) } { Some(b) => b, None => return RC_INVALID_INPUT, }; @@ -975,9 +1116,15 @@ pub unsafe extern "C" fn cpex_invoke_resolved( return RC_PARSE_ERROR; }, }; + if let Err(rc) = + reject_resolved_identity_payload(name, payload.as_ref(), "cpex_invoke_resolved") + { + return rc; + } let base_extensions: Extensions = if extensions_len > 0 { - let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + // SAFETY: the caller guarantees `extensions_msgpack` is NULL or points to `extensions_len` readable bytes. + let ext_bytes = match unsafe { c_bytes_to_slice(extensions_msgpack, extensions_len) } { Some(b) => b, None => return RC_INVALID_INPUT, }; @@ -999,10 +1146,11 @@ pub unsafe extern "C" fn cpex_invoke_resolved( // registered. let merged_extensions: Extensions = if identity_len > 0 && inner - .manager - .has_hooks_for(cpex_core::identity::HOOK_IDENTITY_RESOLVE) + .engine + .has_hooks_for(praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE) { - let id_bytes = match c_bytes_to_slice(identity_msgpack, identity_len) { + // SAFETY: the caller guarantees `identity_msgpack` is NULL or points to `identity_len` readable bytes. + let id_bytes = match unsafe { c_bytes_to_slice(identity_msgpack, identity_len) } { Some(b) => b, None => return RC_INVALID_INPUT, }; @@ -1016,12 +1164,19 @@ pub unsafe extern "C" fn cpex_invoke_resolved( return RC_PARSE_ERROR; }, }; + if let Err(rc) = reject_resolved_identity(&id_payload, "cpex_invoke_resolved/identity") { + return rc; + } + // The identity hook gets the caller's extensions so its `meta` + // selects the route: under `dispatch: policy` the route's + // `authentication:` list decides which resolvers run, and a + // request with no route meta is refused as unidentified. let (id_result, _id_bg) = match run_safely( - inner.manager.invoke_by_name( - cpex_core::identity::HOOK_IDENTITY_RESOLVE, + inner.engine.invoke_by_name( + praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE, Box::new(id_payload), - Extensions::default(), + base_extensions.clone(), None, ), "cpex_invoke_resolved/identity", @@ -1035,15 +1190,19 @@ pub unsafe extern "C" fn cpex_invoke_resolved( // mtls) are synchronous and don't spawn background tasks, so the // identity bg is dropped. if !id_result.continue_processing { - return finish_pipeline_result( - id_result, - None, - payload_type, - result_msgpack_out, - result_len_out, - context_table_out, - bg_handle_out, - ); + // SAFETY: the out pointers are forwarded from this function's + // caller, who guarantees they are writable. + return unsafe { + finish_pipeline_result( + id_result, + None, + payload_type, + result_msgpack_out, + result_len_out, + context_table_out, + bg_handle_out, + ) + }; } match IdentityPayload::from_pipeline_result(&id_result) { @@ -1056,7 +1215,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( let (result, bg) = match run_safely( inner - .manager + .engine .invoke_by_name(name, payload, merged_extensions, input_ctx_table), "cpex_invoke_resolved", ) { @@ -1064,15 +1223,19 @@ pub unsafe extern "C" fn cpex_invoke_resolved( other => return other.rc(), }; - finish_pipeline_result( - result, - Some(bg), - payload_type, - result_msgpack_out, - result_len_out, - context_table_out, - bg_handle_out, - ) + // SAFETY: the out pointers are forwarded from this function's caller, + // who guarantees they are writable. + unsafe { + finish_pipeline_result( + result, + Some(bg), + payload_type, + result_msgpack_out, + result_len_out, + context_table_out, + bg_handle_out, + ) + } } /// Serialize a `(PipelineResult, Option)` into the FFI @@ -1085,7 +1248,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( /// Out pointers must be writable. Returns `RC_OK` on success or /// `RC_SERIALIZE_ERROR` if the result can't be MessagePack-encoded. unsafe fn finish_pipeline_result( - mut result: cpex_core::executor::PipelineResult, + mut result: praxis_policy_core::executor::PipelineResult, bg: Option, payload_type: u8, result_msgpack_out: *mut *mut u8, @@ -1099,13 +1262,17 @@ unsafe fn finish_pipeline_result( Ok((t, b)) => (t, Some(b)), Err(e) => { tracing::warn!("cpex_invoke_resolved: dropped modified payload — {}", e); - result.errors.push(cpex_core::error::PluginErrorRecord { - plugin_name: "".to_string(), - message: format!("modified payload could not be serialized across FFI: {e}"), - code: Some("ffi_serialize_error".to_string()), - details: std::collections::HashMap::new(), - proto_error_code: None, - }); + result + .errors + .push(praxis_policy_core::error::PluginErrorRecord { + plugin_name: "".to_string(), + message: format!( + "modified payload could not be serialized across FFI: {e}" + ), + code: Some("ffi_serialize_error".to_string()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }); (payload_type, None) }, }, @@ -1136,17 +1303,20 @@ unsafe fn finish_pipeline_result( }; let (ptr, len) = alloc_bytes(&result_bytes); - *result_msgpack_out = ptr; - *result_len_out = len; + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *result_msgpack_out = ptr; + *result_len_out = len; - *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { - table: result.context_table, - })); + *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { + table: result.context_table, + })); - *bg_handle_out = match bg { - Some(b) => Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: b })), - None => std::ptr::null_mut(), - }; + *bg_handle_out = match bg { + Some(b) => Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: b })), + None => std::ptr::null_mut(), + }; + } RC_OK } @@ -1159,15 +1329,16 @@ unsafe fn finish_pipeline_result( /// # Safety /// `bg_handle` must be a valid handle from `cpex_invoke`. /// After this call, the handle is consumed and invalid. -/// `mgr` is `*const` — only the runtime is borrowed (`&self`). -#[no_mangle] +/// `handle` is `*const`: only the runtime is borrowed (`&self`). +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_wait_background( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, bg_handle: *mut CpexBackgroundTasksInner, errors_msgpack_out: *mut *mut u8, errors_len_out: *mut c_int, ) -> c_int { - let inner = match mgr.as_ref() { + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { Some(m) => m, None => { // Consume `bg_handle` even on the failure path — the Go @@ -1175,26 +1346,33 @@ pub unsafe extern "C" fn cpex_wait_background( // dropping the Box here we'd leak the BackgroundTasks // allocation (and its still-running task handles). if !bg_handle.is_null() { - drop(Box::from_raw(bg_handle)); + // SAFETY: `bg_handle` is non-null and, per the caller + // contract, a live handle from `cpex_invoke`. + drop(unsafe { Box::from_raw(bg_handle) }); } return RC_INVALID_HANDLE; }, }; if bg_handle.is_null() { - let empty: Vec = Vec::new(); + let empty: Vec = Vec::new(); let empty_bytes = match rmp_serde::to_vec_named(&empty) { Ok(b) => b, Err(_) => return RC_SERIALIZE_ERROR, }; let (ptr, len) = alloc_bytes(&empty_bytes); - *errors_msgpack_out = ptr; - *errors_len_out = len; + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *errors_msgpack_out = ptr; + *errors_len_out = len; + } return RC_OK; } - let bg = Box::from_raw(bg_handle); - // `inner` is now unused but the borrow proved the manager is alive + // SAFETY: `bg_handle` is non-null (checked above) and, per the caller + // contract, a live handle from `cpex_invoke`. + let bg = unsafe { Box::from_raw(bg_handle) }; + // `inner` is now unused but the borrow proved the engine is alive // for the duration of this call (the read lock on the Go side). let _ = inner; let errors = match run_safely(bg.tasks.wait_for_background_tasks(), "cpex_wait_background") { @@ -1205,9 +1383,9 @@ pub unsafe extern "C" fn cpex_wait_background( // Flatten each Rust PluginError variant into the canonical wire // shape so Go callers get structured fields (plugin_name, code, // details) instead of a stringified Display impl. - let ffi_errors: Vec = errors + let ffi_errors: Vec = errors .iter() - .map(cpex_core::error::PluginErrorRecord::from) + .map(praxis_policy_core::error::PluginErrorRecord::from) .collect(); let error_bytes = match rmp_serde::to_vec_named(&ffi_errors) { Ok(b) => b, @@ -1215,8 +1393,11 @@ pub unsafe extern "C" fn cpex_wait_background( }; let (ptr, len) = alloc_bytes(&error_bytes); - *errors_msgpack_out = ptr; - *errors_len_out = len; + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *errors_msgpack_out = ptr; + *errors_len_out = len; + } RC_OK } @@ -1227,10 +1408,12 @@ pub unsafe extern "C" fn cpex_wait_background( /// /// # Safety /// `bg_handle` must be valid or NULL. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_free_background(bg_handle: *mut CpexBackgroundTasksInner) { if !bg_handle.is_null() { - drop(Box::from_raw(bg_handle)); + // SAFETY: `bg_handle` is non-null and, per the caller contract, a + // live handle from `cpex_invoke`. + drop(unsafe { Box::from_raw(bg_handle) }); } } @@ -1238,10 +1421,12 @@ pub unsafe extern "C" fn cpex_free_background(bg_handle: *mut CpexBackgroundTask /// /// # Safety /// `ct` must be valid or NULL. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_free_context_table(ct: *mut CpexContextTableInner) { if !ct.is_null() { - drop(Box::from_raw(ct)); + // SAFETY: `ct` is non-null and, per the caller contract, a live + // context table handle from `cpex_invoke`. + drop(unsafe { Box::from_raw(ct) }); } } @@ -1250,14 +1435,16 @@ pub unsafe extern "C" fn cpex_free_context_table(ct: *mut CpexContextTableInner) /// # Safety /// `ptr` must have been allocated by this library (from `cpex_invoke` /// or `cpex_wait_background`). `len` must match the original allocation. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_free_bytes(ptr: *mut u8, len: c_int) { if ptr.is_null() || len <= 0 { return; } let layout = std::alloc::Layout::from_size_align(len as usize, 1) .expect("align 1 is always valid and len (a positive c_int) is far under isize::MAX"); - std::alloc::dealloc(ptr, layout); + // SAFETY: per the caller contract, `ptr` came from `alloc_bytes` with + // this same `len` and alignment 1, and has not been freed. + unsafe { std::alloc::dealloc(ptr, layout) }; } /// Pipeline result serialized across the FFI boundary. @@ -1271,9 +1458,9 @@ pub unsafe extern "C" fn cpex_free_bytes(ptr: *mut u8, len: c_int) { struct FfiPipelineResult { continue_processing: bool, #[serde(skip_serializing_if = "Option::is_none")] - violation: Option, + violation: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] - errors: Vec, + errors: Vec, #[serde(skip_serializing_if = "Option::is_none")] metadata: Option, /// Payload type ID — tells the Go caller how to deserialize. @@ -1282,7 +1469,7 @@ struct FfiPipelineResult { /// allowed pipeline, carrying the final payload whether or not a /// plugin touched it — read `payload_modified` to learn whether /// anything actually changed. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none", default)] #[serde(with = "serde_bytes_opt")] modified_payload: Option>, /// Whether a plugin's payload modification was accepted. The exact @@ -1292,12 +1479,12 @@ struct FfiPipelineResult { #[serde(default)] payload_modified: bool, /// Modified extensions as raw MessagePack bytes (if a plugin modified them). - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none", default)] #[serde(with = "serde_bytes_opt")] modified_extensions: Option>, } -/// Helper for serializing Option> as binary in MessagePack. +/// Helper for serializing `Option>` as binary in MessagePack. mod serde_bytes_opt { use serde::{Deserializer, Serializer}; @@ -1324,13 +1511,11 @@ pub struct GenericPayload { pub value: serde_json::Value, } -cpex_core::impl_plugin_payload!(GenericPayload); +praxis_policy_core::impl_plugin_payload!(GenericPayload); // These tests call the `extern "C"` functions directly from Rust to // exercise the FFI safety layer (catch_unwind, return-code mapping, -// payload-type validation) without needing a Go test harness. The -// reviewer flagged that `cpex-ffi` had zero `#[cfg(test)]` coverage — -// this seeds the file with regressions for the highest-value invariants. +// payload-type validation) without needing a Go test harness. #[cfg(test)] mod tests { use super::*; @@ -1338,10 +1523,19 @@ mod tests { use std::sync::Arc; use async_trait::async_trait; - use cpex_core::hooks::payload::Extensions; - use cpex_core::hooks::trait_def::HookTypeDef; - use cpex_core::hooks::PluginResult; - use cpex_core::plugin::{Plugin, PluginConfig, PluginMode}; + use praxis_policy_core::hooks::PluginResult; + use praxis_policy_core::hooks::payload::Extensions; + use praxis_policy_core::hooks::trait_def::HookTypeDef; + use praxis_policy_core::plugin::{Plugin, PluginConfig, PluginMode}; + + #[test] + fn one_shot_constructor_rejects_apl_rules_without_a_visitor() { + let yaml = + "routes:\n - tool: lookup\n authorization:\n pre_invocation: [\"deny\"]\n"; + // SAFETY: the YAML slice remains alive for the duration of the call. + let handle = unsafe { cpex_engine_new(yaml.as_ptr().cast(), yaml.len() as c_int) }; + assert!(handle.is_null(), "APL rules must not be silently discarded"); + } /// Test hook type using GenericPayload — that's the type /// PAYLOAD_GENERIC produces at the FFI deserialization boundary, @@ -1368,29 +1562,29 @@ mod tests { } } - impl cpex_core::hooks::HookHandler for PanickingPlugin { + impl praxis_policy_core::hooks::HookHandler for PanickingPlugin { async fn handle( &self, _payload: &GenericPayload, _extensions: &Extensions, - _ctx: &mut cpex_core::context::PluginContext, + _ctx: &mut praxis_policy_core::context::PluginContext, ) -> PluginResult { panic!("simulated panic from PanickingPlugin"); } } - /// Build an FFI-shaped manager for testing. Bypasses - /// `cpex_manager_new` so we can register Rust plugins directly via - /// the manager's typed API. - fn build_test_manager() -> *mut CpexManagerInner { + /// Build an FFI-shaped engine for testing. Bypasses + /// `cpex_engine_new` so we can register Rust plugins directly via + /// the engine's typed API. + fn build_test_engine() -> *mut CpexEngineInner { // Touch the shared runtime so it's initialized; tests use it - // rather than a per-manager runtime. + // rather than a per-engine runtime. let _ = shared_runtime(); - let manager = Arc::new(cpex_core::manager::PluginManager::default()); - Box::into_raw(Box::new(CpexManagerInner { manager })) + let engine = Arc::new(praxis_policy_core::engine::PolicyEngine::default()); + Box::into_raw(Box::new(CpexEngineInner::new(engine))) } - fn register_panicking_plugin(mgr: &CpexManagerInner) { + fn register_panicking_plugin(handle: &CpexEngineInner) { let cfg = PluginConfig { name: "panicker".into(), kind: "test".into(), @@ -1399,7 +1593,8 @@ mod tests { ..Default::default() }; let plugin = Arc::new(PanickingPlugin { cfg: cfg.clone() }); - mgr.manager + handle + .engine .register_handler::(plugin, cfg) .expect("register"); } @@ -1413,78 +1608,101 @@ mod tests { /// Drive cpex_invoke with a single hook name and the given payload. /// Returns the raw rc; output buffers are dropped. unsafe fn invoke_for_test( - mgr: *const CpexManagerInner, + handle: *const CpexEngineInner, payload_type: u8, payload: &[u8], ) -> c_int { + // SAFETY: forwarded caller contract; `handle` is live or NULL. + unsafe { invoke_and_decode(handle, payload_type, payload).0 } + } + + /// Like `invoke_for_test`, also returning the decoded result on + /// `RC_OK`. + unsafe fn invoke_and_decode( + handle: *const CpexEngineInner, + payload_type: u8, + payload: &[u8], + ) -> (c_int, Option) { let hook_name = b"test_hook"; let mut result_ptr: *mut u8 = ptr::null_mut(); let mut result_len: c_int = 0; let mut ct_out: *mut CpexContextTableInner = ptr::null_mut(); let mut bg_out: *mut CpexBackgroundTasksInner = ptr::null_mut(); - let rc = cpex_invoke( - mgr, - hook_name.as_ptr() as *const c_char, - hook_name.len() as c_int, - payload_type, - payload.as_ptr(), - payload.len() as c_int, - ptr::null(), - 0, - ptr::null_mut(), - &mut result_ptr, - &mut result_len, - &mut ct_out, - &mut bg_out, - ); + // SAFETY: `handle` is live or NULL per the caller contract; the + // input slices are borrowed for the call and the out pointers are + // locals. + let rc = unsafe { + cpex_invoke( + handle, + hook_name.as_ptr() as *const c_char, + hook_name.len() as c_int, + payload_type, + payload.as_ptr(), + payload.len() as c_int, + ptr::null(), + 0, + ptr::null_mut(), + &mut result_ptr, + &mut result_len, + &mut ct_out, + &mut bg_out, + ) + }; // Drain any output buffers / handles to avoid leaks across tests. - if !result_ptr.is_null() { - cpex_free_bytes(result_ptr, result_len); - } - if !ct_out.is_null() { - cpex_free_context_table(ct_out); - } - if !bg_out.is_null() { - cpex_free_background(bg_out); + let mut decoded = None; + // SAFETY: every non-null out value was just written by + // `cpex_invoke` and is freed exactly once here. + unsafe { + if !result_ptr.is_null() { + let bytes = std::slice::from_raw_parts(result_ptr, result_len as usize); + decoded = Some(rmp_serde::from_slice(bytes).expect("decode FFI result")); + cpex_free_bytes(result_ptr, result_len); + } + if !ct_out.is_null() { + cpex_free_context_table(ct_out); + } + if !bg_out.is_null() { + cpex_free_background(bg_out); + } } - rc + (rc, decoded) } - /// Panic in a plugin must be caught at the FFI boundary and mapped - /// to `RC_PANIC` rather than unwinding across `extern "C"` (UB on - /// Rust < 1.81; abort on >= 1.81). + /// A panicking plugin must never unwind across `extern "C"`. PPE + /// contains the panic in its executor and reports it as a failed + /// pipeline, so the FFI returns a structured result rather than + /// `RC_PANIC`; `run_safely` stays as the backstop for a panic in the + /// engine itself. #[test] - fn cpex_invoke_returns_rc_panic_when_plugin_panics() { - let mgr = build_test_manager(); - // Defer cleanup so a test failure doesn't leak the manager. - struct ManagerGuard(*mut CpexManagerInner); - impl Drop for ManagerGuard { + fn cpex_invoke_contains_a_plugin_panic() { + let handle = build_test_engine(); + // Defer cleanup so a test failure doesn't leak the engine. + struct EngineGuard(*mut CpexEngineInner); + impl Drop for EngineGuard { fn drop(&mut self) { unsafe { cpex_shutdown(self.0); } } } - let _guard = ManagerGuard(mgr); + let _guard = EngineGuard(handle); unsafe { - let inner = &*mgr; + let inner = &*handle; register_panicking_plugin(inner); - // Manager must be initialized for the pipeline to dispatch. - let init_rc = cpex_initialize(mgr); + // Engine must be initialized for the pipeline to dispatch. + let init_rc = cpex_initialize(handle); assert_eq!(init_rc, RC_OK, "init should succeed"); - // Invoke with the registered hook — plugin panics, caught - // by run_safely's catch_unwind, mapped to RC_PANIC. let bytes = payload_bytes("trigger"); - let rc = invoke_for_test(mgr, PAYLOAD_GENERIC, &bytes); - assert_eq!( - rc, RC_PANIC, - "panic should be caught and surfaced as RC_PANIC, got {}", - rc, - ); + let (rc, result) = invoke_and_decode(handle, PAYLOAD_GENERIC, &bytes); + assert_eq!(rc, RC_OK, "the engine contains the panic"); + let result = result.expect("a decodable result"); + assert!(!result.continue_processing); + let violation = result.violation.expect("the panic is reported"); + assert_eq!(violation.code, "plugin_panic"); } } @@ -1494,25 +1712,25 @@ mod tests { /// generic failure. #[test] fn cpex_invoke_returns_rc_parse_error_on_unknown_payload_type() { - let mgr = build_test_manager(); - struct ManagerGuard(*mut CpexManagerInner); - impl Drop for ManagerGuard { + let handle = build_test_engine(); + struct EngineGuard(*mut CpexEngineInner); + impl Drop for EngineGuard { fn drop(&mut self) { unsafe { cpex_shutdown(self.0); } } } - let _guard = ManagerGuard(mgr); + let _guard = EngineGuard(handle); unsafe { - let inner = &*mgr; + let inner = &*handle; register_panicking_plugin(inner); // need *some* plugin so dispatch runs - assert_eq!(cpex_initialize(mgr), RC_OK); + assert_eq!(cpex_initialize(handle), RC_OK); // Unknown payload type — dispatch never reaches the plugin. let bytes = payload_bytes("trigger"); - let rc = invoke_for_test(mgr, 99 /* not in registry */, &bytes); + let rc = invoke_for_test(handle, 99 /* not in registry */, &bytes); assert_eq!( rc, RC_PARSE_ERROR, "unknown payload_type should map to RC_PARSE_ERROR, got {}", @@ -1521,48 +1739,24 @@ mod tests { } } - /// `worker_threads_from_env` parses CPEX_FFI_WORKER_THREADS into - /// a positive count, returning None for unset / zero / negative / - /// unparseable. This isolates the env-parsing logic from the - /// OnceLock-init path so we can test it deterministically. + /// `parse_worker_threads` turns a CPEX_FFI_WORKER_THREADS value into + /// a positive count, returning None for zero / negative / + /// unparseable. It takes the raw string so the test never mutates + /// the process environment, which would race other test threads. #[test] - fn worker_threads_from_env_parses_correctly() { - // Use a unique env var name per test invocation isn't possible - // (the function reads ENV_WORKER_THREADS specifically), so we - // serialize manipulation: set, read, restore. Run-time tests - // don't currently parallelize this var across threads. - let prev = std::env::var(ENV_WORKER_THREADS).ok(); - - // SAFETY: tests are single-threaded with respect to this env - // var (no other test reads/writes it). std::env::set_var is - // unsafe in multi-threaded programs reading other env vars - // concurrently; we accept that risk in the test harness. - let restore = |v: Option| unsafe { - match v { - Some(s) => std::env::set_var(ENV_WORKER_THREADS, s), - None => std::env::remove_var(ENV_WORKER_THREADS), - } - }; - - unsafe { - std::env::set_var(ENV_WORKER_THREADS, "8"); - assert_eq!(worker_threads_from_env(), Some(8)); - - std::env::set_var(ENV_WORKER_THREADS, "0"); - assert_eq!(worker_threads_from_env(), None, "zero should fall back"); - - std::env::set_var(ENV_WORKER_THREADS, "garbage"); - assert_eq!( - worker_threads_from_env(), - None, - "unparseable should fall back" - ); - - std::env::remove_var(ENV_WORKER_THREADS); - assert_eq!(worker_threads_from_env(), None, "unset should be None"); - } - - restore(prev); + fn parse_worker_threads_parses_correctly() { + assert_eq!(parse_worker_threads("8"), Some(8)); + assert_eq!(parse_worker_threads("0"), None, "zero should fall back"); + assert_eq!( + parse_worker_threads("-3"), + None, + "negative should fall back" + ); + assert_eq!( + parse_worker_threads("garbage"), + None, + "unparseable should fall back" + ); } /// `cpex_configure_runtime` rejects non-positive worker counts @@ -1575,7 +1769,7 @@ mod tests { } /// Once the shared runtime is initialized (e.g., by any prior test - /// or `cpex_manager_new` call), subsequent configure attempts must + /// or `cpex_engine_new` call), subsequent configure attempts must /// fail with `RC_INVALID_INPUT` — the runtime is single-init. #[test] fn cpex_configure_runtime_after_init_returns_invalid_input() { @@ -1601,6 +1795,36 @@ mod tests { assert_eq!(value, serde_json::json!({ "k": "v" })); } + /// An identity result keeps its input and resolved fields but drops the + /// request `headers`, which carry the inbound credential. + #[test] + fn serialize_payload_drops_identity_headers() { + let idp: IdentityPayload = serde_json::from_value(serde_json::json!({ + "source": "bearer", + "source_header": "authorization", + "headers": {"authorization": "Bearer secret-token", "x-trace": "t1"}, + "client_host": "10.0.0.1", + "client_port": 443, + "subject": {"id": "alice"}, + "raw_claims": {"sub": "alice"}, + })) + .unwrap(); + let (t, bytes) = serialize_payload(&idp).expect("identity should serialize"); + assert_eq!(t, PAYLOAD_IDENTITY); + let value: serde_json::Value = rmp_serde::from_slice(&bytes).expect("round-trip decode"); + assert!(value.get("headers").is_none(), "{value}"); + assert!(!value.to_string().contains("secret-token"), "{value}"); + assert_eq!(value["source"], "bearer"); + assert_eq!(value["source_header"], "authorization"); + assert_eq!(value["client_host"], "10.0.0.1"); + assert_eq!(value["client_port"], 443); + assert_eq!(value["subject"]["id"], "alice"); + assert_eq!(value["raw_claims"]["sub"], "alice"); + // The bytes still decode as an identity payload on the other side. + let back: IdentityPayload = rmp_serde::from_slice(&bytes).expect("decode as identity"); + assert!(back.subject.is_some()); + } + /// `serialize_payload` returns `Err` for payload types the FFI /// registry doesn't know about. Without this contract the FFI /// silently dropped a plugin's modification — the caller saw @@ -1632,7 +1856,7 @@ mod tests { ); } - /// A null manager handle must return `RC_INVALID_HANDLE` from + /// A null engine handle must return `RC_INVALID_HANDLE` from /// every entry point — guards the `as_ref()` precondition. #[test] fn cpex_invoke_returns_rc_invalid_handle_on_null_mgr() { @@ -1646,52 +1870,354 @@ mod tests { } } + #[test] + fn cpex_ffi_abi_version_is_3() { + assert_eq!(cpex_ffi_abi_version(), 3); + assert_eq!(FFI_ABI_VERSION, 3); + } + #[test] fn cpex_apl_install_rejects_null_handle() { unsafe { - assert_eq!(crate::apl::cpex_apl_install(ptr::null()), RC_INVALID_HANDLE); + assert_eq!(cpex_apl_install(ptr::null(), 0), RC_INVALID_HANDLE); } } - /// Full APL flow through the FFI surface: default manager → - /// cpex_apl_install (registers bundled factories + APL visitor) → - /// cpex_load_config over an APL-annotated YAML using a bundled - /// plugin kind (`audit/logger`) → cpex_initialize. Proves the visitor - /// walk runs (load uses load_config_yaml) and the bundled factory is - /// reachable, so the plugin actually instantiates. #[test] - fn cpex_apl_install_then_load_apl_config_initializes() { - const YAML: &str = r#" + fn cpex_apl_install_rejects_unknown_flag_bits() { + let handle = cpex_engine_new_default(); + unsafe { + assert_eq!(cpex_apl_install(handle, 1 << 31), RC_INVALID_INPUT); + // A rejected call installs nothing, so a valid one still runs. + assert_eq!(cpex_apl_install(handle, 0), RC_OK); + cpex_shutdown(handle); + } + } + + /// `cpex_invoke` on `hook` with no context table; frees every output + /// and returns the rc. + unsafe fn invoke_named( + handle: *const CpexEngineInner, + hook: &str, + payload_type: u8, + payload: &[u8], + ) -> c_int { + let mut out: *mut u8 = ptr::null_mut(); + let mut out_len: c_int = 0; + let mut ct: *mut CpexContextTableInner = ptr::null_mut(); + let mut bg: *mut CpexBackgroundTasksInner = ptr::null_mut(); + // SAFETY: `handle` is live per the caller contract; inputs are + // borrowed for the call and the out pointers are locals, each + // freed once below. + unsafe { + let rc = cpex_invoke( + handle, + hook.as_ptr() as *const c_char, + hook.len() as c_int, + payload_type, + payload.as_ptr(), + payload.len() as c_int, + ptr::null(), + 0, + ptr::null_mut(), + &mut out, + &mut out_len, + &mut ct, + &mut bg, + ); + free_outputs(out, out_len, ct, bg); + rc + } + } + + /// `cpex_invoke_resolved` with an identity payload; frees every + /// output and returns the rc. + unsafe fn invoke_resolved_named( + handle: *const CpexEngineInner, + identity: &[u8], + hook: &str, + payload_type: u8, + payload: &[u8], + ) -> c_int { + let mut out: *mut u8 = ptr::null_mut(); + let mut out_len: c_int = 0; + let mut ct: *mut CpexContextTableInner = ptr::null_mut(); + let mut bg: *mut CpexBackgroundTasksInner = ptr::null_mut(); + // SAFETY: as in `invoke_named`. + unsafe { + let rc = cpex_invoke_resolved( + handle, + identity.as_ptr(), + identity.len() as c_int, + hook.as_ptr() as *const c_char, + hook.len() as c_int, + payload_type, + payload.as_ptr(), + payload.len() as c_int, + ptr::null(), + 0, + ptr::null_mut(), + &mut out, + &mut out_len, + &mut ct, + &mut bg, + ); + free_outputs(out, out_len, ct, bg); + rc + } + } + + unsafe fn free_outputs( + out: *mut u8, + out_len: c_int, + ct: *mut CpexContextTableInner, + bg: *mut CpexBackgroundTasksInner, + ) { + // SAFETY: each non-null value was written by an invoke and is + // freed exactly once here. + unsafe { + if !out.is_null() { + cpex_free_bytes(out, out_len); + } + if !ct.is_null() { + cpex_free_context_table(ct); + } + if !bg.is_null() { + cpex_free_background(bg); + } + } + } + + fn identity_bytes(value: serde_json::Value) -> Vec { + rmp_serde::to_vec_named(&value).expect("encode identity payload") + } + + /// A removed hook name is refused at invoke time instead of + /// dispatching to nothing and allowing. + #[test] + fn invoke_rejects_removed_hook_names() { + let handle = build_test_engine(); + let payload = payload_bytes("x"); + unsafe { + for (removed, _) in cpex::host::REMOVED_HOOKS { + assert_eq!( + invoke_named(handle, removed, PAYLOAD_GENERIC, &payload), + RC_INVALID_INPUT, + "{removed}" + ); + assert_eq!( + invoke_resolved_named(handle, &[], removed, PAYLOAD_GENERIC, &payload), + RC_INVALID_INPUT, + "{removed}" + ); + } + // A current name with no handler still dispatches and allows. + assert_eq!( + invoke_named(handle, "cmf.tool_pre_invoke", PAYLOAD_GENERIC, &payload), + RC_OK + ); + assert_eq!( + invoke_named(handle, "custom.hook", PAYLOAD_GENERIC, &payload), + RC_OK + ); + cpex_shutdown(handle); + } + } + + /// An `identity.resolve` input carrying resolver output is refused on + /// both invoke entry points; a plain input is accepted. + #[test] + fn invoke_rejects_identity_input_with_resolved_fields() { + let input = identity_bytes(serde_json::json!({ + "source": "bearer", + "headers": {"authorization": "Bearer x"}, + })); + let forged = identity_bytes(serde_json::json!({ + "source": "bearer", + "headers": {"authorization": "Bearer x"}, + "subject": {"id": "alice"}, + })); + let generic = payload_bytes("x"); + unsafe { + let handle = cpex_engine_new_default(); + assert_eq!(cpex_apl_install(handle, 0), RC_OK); + // A registered identity.resolve handler, so the fused path + // reads its identity payload. + assert_eq!(load(handle, APL_YAML), RC_OK); + + assert_eq!( + invoke_named(handle, "identity.resolve", PAYLOAD_IDENTITY, &forged), + RC_INVALID_INPUT + ); + assert_eq!( + invoke_resolved_named( + handle, + &forged, + "cmf.tool_pre_invoke", + PAYLOAD_GENERIC, + &generic + ), + RC_INVALID_INPUT + ); + assert_eq!( + invoke_resolved_named(handle, &[], "identity.resolve", PAYLOAD_IDENTITY, &forged), + RC_INVALID_INPUT + ); + cpex_shutdown(handle); + + // With no handler on identity.resolve the plain input + // dispatches and allows. + let bare = build_test_engine(); + assert_eq!( + invoke_named(bare, "identity.resolve", PAYLOAD_IDENTITY, &input), + RC_OK + ); + cpex_shutdown(bare); + } + } + + /// An install that panicked stays failed: a retry returns RC_PANIC + /// rather than RC_OK over a half-installed engine. + #[test] + fn cpex_apl_install_after_a_panic_keeps_failing() { + unsafe { + let handle = cpex_engine_new_default(); + (*handle) + .fail_install + .store(true, std::sync::atomic::Ordering::Relaxed); + assert_eq!(cpex_apl_install(handle, 0), RC_PANIC); + + // Clearing the injection does not help: the state is final. + (*handle) + .fail_install + .store(false, std::sync::atomic::Ordering::Relaxed); + assert_eq!(cpex_apl_install(handle, 0), RC_PANIC); + assert_eq!( + *(*handle).install_state.lock().unwrap(), + apl::InstallState::Failed + ); + cpex_shutdown(handle); + } + } + + /// An identity-jwt plugin whose JWKS sits on a loopback port nothing + /// listens on, plus one APL route. With private destinations blocked + /// the fetch never leaves the process, so initialize soft-fails + /// without touching the network. + const APL_YAML: &str = r#" plugins: - - name: auditor - kind: audit/logger - hooks: [cmf.tool_pre_invoke] + - name: jwt + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: user + header: Authorization + trusted_issuers: + - issuer: "https://idp.test.local" + audiences: ["test-api"] + algorithms: ["ES256"] + decoding_key: + kind: jwks_url + url: "http://127.0.0.1:9/jwks" + insecure_http: true + claim_mapper: standard +global: + authentication: [jwt] routes: - tool: get_weather authorization: pre_invocation: - - "plugin(auditor)" + - "require(role.hr)" "#; + + unsafe fn load(handle: *const CpexEngineInner, yaml: &str) -> c_int { + // SAFETY: forwarded caller contract; `yaml` is borrowed for the call. + unsafe { cpex_load_config(handle, yaml.as_ptr() as *const c_char, yaml.len() as c_int) } + } + + unsafe fn has_hook(handle: *const CpexEngineInner, hook: &str) -> c_int { + // SAFETY: forwarded caller contract; `hook` is borrowed for the call. + unsafe { cpex_has_hooks_for(handle, hook.as_ptr() as *const c_char, hook.len() as c_int) } + } + + /// Full APL flow through the FFI surface: default engine, then + /// cpex_apl_install (bundled factories and the APL visitor), then + /// cpex_load_config over an APL-annotated YAML using a bundled plugin + /// kind (`identity/jwt`), then cpex_initialize. Proves the visitor walk + /// runs and the bundled factory is reachable. + #[test] + fn cpex_apl_install_then_load_apl_config_initializes() { + unsafe { + let handle = cpex_engine_new_default(); + + assert_eq!(cpex_apl_install(handle, 0), RC_OK); + assert_eq!( + load(handle, APL_YAML), + RC_OK, + "load of APL config should succeed" + ); + assert_eq!(cpex_initialize(handle), RC_OK); + + assert_eq!(cpex_plugin_count(handle), 1); + assert_eq!(has_hook(handle, "identity.resolve"), 1); + // The route's APL handler sits on the CMF tool hook. + assert_eq!(has_hook(handle, "cmf.tool_pre_invoke"), 1); + + cpex_shutdown(handle); + } + } + + /// The one-shot constructor stays builtin-free: a config naming a + /// bundled kind fails to load because no factory is registered. + #[test] + fn cpex_engine_new_does_not_install_builtins() { unsafe { - let mgr = build_test_manager(); + let handle = + cpex_engine_new(APL_YAML.as_ptr() as *const c_char, APL_YAML.len() as c_int); + assert!( + handle.is_null(), + "identity/jwt must not resolve without cpex_apl_install" + ); + } + } - assert_eq!(crate::apl::cpex_apl_install(mgr), RC_OK); + /// A second install is a no-op: it returns RC_OK, ignores its flags, + /// and a later config load yields the same plugin and hook layout as + /// a single install. + #[test] + fn cpex_apl_install_twice_is_a_no_op() { + unsafe { + let once = cpex_engine_new_default(); + assert_eq!(cpex_apl_install(once, 0), RC_OK); - let rc = cpex_load_config(mgr, YAML.as_ptr() as *const c_char, YAML.len() as c_int); - assert_eq!(rc, RC_OK, "load of APL config should succeed"); + let twice = cpex_engine_new_default(); + assert_eq!(cpex_apl_install(twice, 0), RC_OK); + assert_eq!( + cpex_apl_install(twice, CPEX_APL_ALLOW_PRIVATE_DESTINATIONS), + RC_OK + ); - assert_eq!(cpex_initialize(mgr), RC_OK); + for handle in [once, twice] { + assert_eq!(load(handle, APL_YAML), RC_OK); + assert_eq!(cpex_initialize(handle), RC_OK); + } - // The bundled `audit/logger` factory instantiated a plugin on - // cmf.tool_pre_invoke — proves cpex_apl_install wired the kind. - assert!(cpex_plugin_count(mgr) >= 1); - let hook = "cmf.tool_pre_invoke"; + assert_eq!(cpex_plugin_count(once), cpex_plugin_count(twice)); assert_eq!( - cpex_has_hooks_for(mgr, hook.as_ptr() as *const c_char, hook.len() as c_int), - 1, + (*once).engine.plugin_names(), + (*twice).engine.plugin_names() ); + for hook in [ + "identity.resolve", + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + ] { + assert_eq!(has_hook(once, hook), has_hook(twice, hook), "{hook}"); + } - cpex_shutdown(mgr); + cpex_shutdown(once); + cpex_shutdown(twice); } } } diff --git a/crates/cpex-ffi/src/secrets.rs b/crates/cpex-ffi/src/secrets.rs new file mode 100644 index 00000000..402f9c02 --- /dev/null +++ b/crates/cpex-ffi/src/secrets.rs @@ -0,0 +1,145 @@ +// Location: ./crates/cpex-ffi/src/secrets.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Secret refresh and health over the FFI. +// +// PPE resolves every declared secret once, at `cpex_initialize`, and never +// re-reads on its own: the host decides when. `cpex_refresh_secrets` +// re-reads every value (renewing a Vault token when due) and returns what +// changed and what failed. `cpex_secrets_last_success` reports when a +// provider last re-read all of its values cleanly, so a host can alarm on +// a stale credential. + +use std::os::raw::{c_char, c_int}; + +use cpex::host::{RefreshSummary, secret_error_kind}; + +use crate::{ + CpexEngineInner, RC_INVALID_HANDLE, RC_INVALID_INPUT, RC_OK, RC_SERIALIZE_ERROR, SafeRun, + alloc_bytes, c_str_to_slice, run_safely, +}; + +/// Refresh report as it crosses the FFI, MessagePack-encoded with named +/// fields. +pub type FfiRefreshReport = RefreshSummary; + +/// Re-read every declared secret and return what changed and what failed. +/// +/// On `RC_OK`, `*report_msgpack_out` / `*report_len_out` hold a +/// MessagePack map `{updated: [str], unchanged: uint, failed: [{secret, +/// provider, kind, message}]}`; free it with `cpex_free_bytes`. A failed +/// re-read is not an error return: it is listed in `failed` and the value +/// keeps its last-good bytes. An engine with no `secrets:` block, or not +/// yet initialized, returns an empty report. +/// +/// Returns `RC_OK`, `RC_INVALID_HANDLE` for a null handle, +/// `RC_INVALID_INPUT` for a null out pointer, `RC_SERIALIZE_ERROR`, or +/// `RC_TIMEOUT` / `RC_PANIC` from the FFI safety layer. Out params are +/// null/zero on any non-OK return. +/// +/// # Safety +/// `handle` must be a valid engine handle or NULL. Out pointers must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_refresh_secrets( + handle: *const CpexEngineInner, + report_msgpack_out: *mut *mut u8, + report_len_out: *mut c_int, +) -> c_int { + if report_msgpack_out.is_null() || report_len_out.is_null() { + return RC_INVALID_INPUT; + } + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *report_msgpack_out = std::ptr::null_mut(); + *report_len_out = 0; + } + + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { + Some(h) => h, + None => return RC_INVALID_HANDLE, + }; + + let report = match run_safely(inner.engine.refresh_secrets(), "cpex_refresh_secrets") { + SafeRun::Ok(r) => r, + other => return other.rc(), + }; + // Log only the names and the failure class. The full message stays in + // the report handed back to the caller. + for failure in &report.failed { + tracing::warn!( + secret = %failure.secret, + provider = %failure.provider, + kind = secret_error_kind(&failure.source), + "cpex_refresh_secrets: secret refresh failed" + ); + } + + let wire = FfiRefreshReport::from(report); + let bytes = match rmp_serde::to_vec_named(&wire) { + Ok(b) => b, + Err(e) => { + tracing::error!("cpex_refresh_secrets: report serialize failed: {}", e); + return RC_SERIALIZE_ERROR; + }, + }; + let (ptr, len) = alloc_bytes(&bytes); + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *report_msgpack_out = ptr; + *report_len_out = len; + } + RC_OK +} + +/// When `provider` last re-read all of its values without a failure, in +/// Unix milliseconds (UTC). +/// +/// On `RC_OK`, `*unix_ms_out` holds the timestamp, or `0` when there is +/// none: the provider has never refreshed successfully, the name is not a +/// declared provider, or secrets are not resolved yet. Initialization +/// counts as the first success. Failed refreshes retain that timestamp; +/// inspect the refresh report's `failed` entries to detect an outage. +/// +/// Returns `RC_OK`, `RC_INVALID_HANDLE` for a null handle, or +/// `RC_INVALID_INPUT` for a null out pointer or a missing / non-UTF-8 +/// provider name. +/// +/// # Safety +/// `handle` must be a valid engine handle or NULL. `provider` must point +/// to `provider_len` bytes. `unix_ms_out` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cpex_secrets_last_success( + handle: *const CpexEngineInner, + provider: *const c_char, + provider_len: c_int, + unix_ms_out: *mut i64, +) -> c_int { + if unix_ms_out.is_null() { + return RC_INVALID_INPUT; + } + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *unix_ms_out = 0; + } + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { + Some(h) => h, + None => return RC_INVALID_HANDLE, + }; + // SAFETY: the caller guarantees `provider` is NULL or points to `provider_len` readable bytes. + let name = match unsafe { c_str_to_slice(provider, provider_len) } { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + if let Some(at) = inner.engine.secrets_last_success(name) { + // SAFETY: the caller guarantees the output pointers are writable. + unsafe { + *unix_ms_out = at.timestamp_millis(); + } + } + RC_OK +} diff --git a/crates/cpex-ffi/tests/common/mod.rs b/crates/cpex-ffi/tests/common/mod.rs new file mode 100644 index 00000000..f1f12639 --- /dev/null +++ b/crates/cpex-ffi/tests/common/mod.rs @@ -0,0 +1,274 @@ +// Location: ./crates/cpex-ffi/tests/common/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Shared helpers for the FFI integration tests: a tiny scripted HTTP +// server on loopback, a warning-capturing tracing subscriber, and thin +// wrappers over the C entry points. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::fmt::Write as _; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::os::raw::{c_char, c_int}; +use std::ptr; +use std::sync::{Arc, Mutex, OnceLock}; + +use cpex_ffi::{ + CpexBackgroundTasksInner, CpexContextTableInner, CpexEngineInner, RC_OK, cpex_apl_install, + cpex_engine_new_default, cpex_free_background, cpex_free_bytes, cpex_free_context_table, + cpex_initialize, cpex_invoke, cpex_load_config, +}; + +// --------------------------------------------------------------------- +// Scripted HTTP server +// --------------------------------------------------------------------- + +type Routes = Arc>>; + +/// A loopback HTTP/1.1 server answering each path with a scripted status +/// and JSON body. One thread per connection, `Connection: close` on every +/// response. Unscripted paths answer 404. +pub struct MockServer { + port: u16, + routes: Routes, + hits: Arc>>, +} + +impl MockServer { + pub fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().expect("local addr").port(); + let routes: Routes = Arc::default(); + let hits: Arc>> = Arc::default(); + let (r, h) = (Arc::clone(&routes), Arc::clone(&hits)); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let (r, h) = (Arc::clone(&r), Arc::clone(&h)); + std::thread::spawn(move || serve(stream, &r, &h)); + } + }); + Self { port, routes, hits } + } + + pub fn set(&self, path: &str, status: u16, body: &str) { + self.routes + .lock() + .unwrap() + .insert(path.to_owned(), (status, body.to_owned())); + } + + pub fn origin(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + pub fn url(&self, path: &str) -> String { + format!("{}{path}", self.origin()) + } + + pub fn hits(&self, path: &str) -> usize { + self.hits.lock().unwrap().get(path).copied().unwrap_or(0) + } +} + +fn serve(stream: TcpStream, routes: &Routes, hits: &Mutex>) { + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let path = target.split('?').next().unwrap_or("/").to_owned(); + + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = value.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + let _ = reader.read_exact(&mut body); + + *hits.lock().unwrap().entry(path.clone()).or_insert(0) += 1; + let (status, payload) = routes + .lock() + .unwrap() + .get(&path) + .cloned() + .unwrap_or_else(|| (404, r#"{"errors":["not found"]}"#.to_owned())); + let response = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{payload}", + payload.len() + ); + let mut stream = stream; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +// --------------------------------------------------------------------- +// Warning capture +// --------------------------------------------------------------------- + +static LOGS: Mutex> = Mutex::new(Vec::new()); + +struct Capture; + +struct Fields(String); + +impl tracing::field::Visit for Fields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + let _ = write!(self.0, "{}={:?} ", field.name(), value); + } +} + +impl tracing::Subscriber for Capture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + *metadata.level() <= tracing::Level::WARN + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + let mut fields = Fields(String::new()); + event.record(&mut fields); + LOGS.lock().unwrap().push(fields.0); + } + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} +} + +/// Install the process-wide warning capture. Safe to call from every test. +pub fn capture_warnings() { + static INSTALLED: OnceLock<()> = OnceLock::new(); + INSTALLED.get_or_init(|| { + let _ = tracing::subscriber::set_global_default(Capture); + }); +} + +/// Every captured warning or error line containing all of `needles`. +pub fn warnings_containing(needles: &[&str]) -> Vec { + LOGS.lock() + .unwrap() + .iter() + .filter(|line| needles.iter().all(|n| line.contains(n))) + .cloned() + .collect() +} + +// --------------------------------------------------------------------- +// FFI wrappers +// --------------------------------------------------------------------- + +/// Owns an engine handle and shuts it down on drop. +pub struct Engine(pub *mut CpexEngineInner); + +impl Engine { + /// `cpex_engine_new_default` then `cpex_apl_install(flags)`. + pub fn installed(flags: u32) -> Self { + let handle = cpex_engine_new_default(); + assert!(!handle.is_null()); + assert_eq!(unsafe { cpex_apl_install(handle, flags) }, RC_OK); + Self(handle) + } + + pub fn inner(&self) -> &CpexEngineInner { + unsafe { &*self.0 } + } + + pub fn load(&self, yaml: &str) -> c_int { + unsafe { cpex_load_config(self.0, yaml.as_ptr() as *const c_char, yaml.len() as c_int) } + } + + pub fn initialize(&self) -> c_int { + unsafe { cpex_initialize(self.0) } + } + + /// `cpex_invoke` without a context table; returns the rc and, on + /// `RC_OK`, the decoded result. + pub fn invoke( + &self, + hook: &str, + payload_type: u8, + payload: &[u8], + ) -> (c_int, Option) { + let mut out: *mut u8 = ptr::null_mut(); + let mut out_len: c_int = 0; + let mut ct: *mut CpexContextTableInner = ptr::null_mut(); + let mut bg: *mut CpexBackgroundTasksInner = ptr::null_mut(); + unsafe { + let rc = cpex_invoke( + self.0, + hook.as_ptr() as *const c_char, + hook.len() as c_int, + payload_type, + payload.as_ptr(), + payload.len() as c_int, + ptr::null(), + 0, + ptr::null_mut(), + &mut out, + &mut out_len, + &mut ct, + &mut bg, + ); + let mut decoded = None; + if !out.is_null() { + let bytes = std::slice::from_raw_parts(out, out_len as usize); + decoded = Some(rmp_serde::from_slice(bytes).expect("decode FFI result")); + cpex_free_bytes(out, out_len); + } + if !ct.is_null() { + cpex_free_context_table(ct); + } + if !bg.is_null() { + cpex_free_background(bg); + } + (rc, decoded) + } + } +} + +impl Drop for Engine { + fn drop(&mut self) { + unsafe { cpex_ffi::cpex_shutdown(self.0) }; + } +} + +/// The FFI pipeline result as a host decodes it. +#[derive(Debug, serde::Deserialize)] +pub struct FfiResult { + pub continue_processing: bool, + #[serde(default)] + pub violation: Option, + #[serde(default)] + pub payload_type: u8, + #[serde(default)] + pub modified_payload: Option, +} + +impl FfiResult { + pub fn violation_code(&self) -> Option<&str> { + self.violation.as_ref()?.get("code")?.as_str() + } +} + +/// Run a future to completion on a private current-thread runtime. +pub fn block_on(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(fut) +} diff --git a/crates/cpex-ffi/tests/egress.rs b/crates/cpex-ffi/tests/egress.rs new file mode 100644 index 00000000..860bb3f2 --- /dev/null +++ b/crates/cpex-ffi/tests/egress.rs @@ -0,0 +1,463 @@ +// Location: ./crates/cpex-ffi/tests/egress.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Outbound HTTP through the transport `cpex_apl_install` builds: the +// private-destination flag, the egress-denied codes builtins report when +// it is off, and Vault secret refresh over the same transport. + +mod common; + +use std::collections::HashMap; +use std::os::raw::{c_char, c_int}; +use std::ptr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}; +use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, PublicKeyUse}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde_json::json; + +use cpex_ffi::{ + CPEX_APL_ALLOW_PRIVATE_DESTINATIONS, PAYLOAD_IDENTITY, RC_INVALID_HANDLE, RC_INVALID_INPUT, + RC_OK, RC_PIPELINE_ERROR, cpex_free_bytes, cpex_refresh_secrets, cpex_secrets_last_success, +}; +use praxis_policy_core::delegation::{DelegationPayload, HOOK_TOKEN_DELEGATE, TokenDelegateHook}; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::identity::{IdentityPayload, TokenSource}; + +use common::{Engine, MockServer, block_on, capture_warnings, warnings_containing}; + +const AUD: &str = "test-api"; +const JWKS_PATH: &str = "/realms/test/certs"; + +// --------------------------------------------------------------------- +// identity-jwt with a jwks_url +// --------------------------------------------------------------------- + +/// A fresh P-256 key, its JWKS document, and a signer. +struct Signer { + key: EncodingKey, + jwks: String, +} + +impl Signer { + fn new() -> Self { + let pkcs8 = + EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &SystemRandom::new()) + .expect("generate P-256 key"); + let key = EncodingKey::from_ec_der(pkcs8.as_ref()); + let mut jwk = Jwk::from_encoding_key(&key, Algorithm::ES256).expect("public JWK"); + jwk.common.key_id = Some("k1".into()); + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + jwk.common.key_algorithm = Some(KeyAlgorithm::ES256); + let jwks = serde_json::to_string(&JwkSet { keys: vec![jwk] }).expect("JWKS"); + Self { key, jwks } + } + + fn token(&self, issuer: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("k1".into()); + let claims = json!({ + "iss": issuer, + "aud": AUD, + "sub": "alice@corp.com", + "iat": now, + "exp": now + 300, + }); + encode(&header, &claims, &self.key).expect("sign JWT") + } +} + +fn jwt_config(issuer: &str, jwks_url: &str) -> String { + format!( + r#" +engine_settings: + dispatch: hooks +plugins: + - name: jwt + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: user + header: Authorization + trusted_issuers: + - issuer: "{issuer}" + audiences: ["{AUD}"] + algorithms: ["ES256"] + decoding_key: + kind: jwks_url + url: "{jwks_url}" + insecure_http: true + claim_mapper: standard +"# + ) +} + +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); + rmp_serde::to_vec_named(&payload).expect("encode identity payload") +} + +#[test] +fn jwks_on_loopback_validates_when_private_destinations_are_allowed() { + let signer = Signer::new(); + let server = MockServer::start(); + server.set(JWKS_PATH, 200, &signer.jwks); + let issuer = "https://idp.allowed.test"; + + let engine = Engine::installed(CPEX_APL_ALLOW_PRIVATE_DESTINATIONS); + assert_eq!( + engine.load(&jwt_config(issuer, &server.url(JWKS_PATH))), + RC_OK + ); + assert_eq!(engine.initialize(), RC_OK); + assert!(server.hits(JWKS_PATH) >= 1, "initialize fetched the JWKS"); + + let (rc, result) = engine.invoke( + "identity.resolve", + PAYLOAD_IDENTITY, + &identity_payload(&signer.token(issuer)), + ); + assert_eq!(rc, RC_OK); + let result = result.expect("result"); + assert!( + result.continue_processing, + "token must validate: {:?}", + result.violation + ); + assert_eq!(result.payload_type, PAYLOAD_IDENTITY); + let resolved: IdentityPayload = + rmp_serde::from_slice(result.modified_payload.as_ref().expect("resolved identity")) + .expect("decode identity"); + let subject = resolved.subject.expect("subject populated"); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); +} + +#[test] +fn jwks_on_loopback_soft_fails_when_private_destinations_are_blocked() { + capture_warnings(); + let signer = Signer::new(); + let server = MockServer::start(); + server.set(JWKS_PATH, 200, &signer.jwks); + let issuer = "https://idp.blocked.test"; + + let engine = Engine::installed(0); + assert_eq!( + engine.load(&jwt_config(issuer, &server.url(JWKS_PATH))), + RC_OK + ); + assert_eq!( + engine.initialize(), + RC_OK, + "a refused JWKS fetch soft-fails" + ); + assert!( + !warnings_containing(&["initial JWKS fetch failed", issuer]).is_empty(), + "initialize must warn about the failed fetch" + ); + + let (rc, result) = engine.invoke( + "identity.resolve", + PAYLOAD_IDENTITY, + &identity_payload(&signer.token(issuer)), + ); + assert_eq!(rc, RC_OK); + let result = result.expect("result"); + assert!(!result.continue_processing); + assert_eq!(result.violation_code(), Some("auth.jwks_unavailable")); + assert_eq!( + server.hits(JWKS_PATH), + 0, + "nothing reached the loopback server" + ); +} + +/// The first install fixes the transport: a second call with the +/// private-destination flag does not open loopback egress. +#[test] +fn a_second_install_does_not_change_the_egress_policy() { + let signer = Signer::new(); + let server = MockServer::start(); + server.set(JWKS_PATH, 200, &signer.jwks); + let issuer = "https://idp.reinstall.test"; + + let engine = Engine::installed(0); + assert_eq!( + unsafe { cpex_ffi::cpex_apl_install(engine.0, CPEX_APL_ALLOW_PRIVATE_DESTINATIONS) }, + RC_OK + ); + assert_eq!( + engine.load(&jwt_config(issuer, &server.url(JWKS_PATH))), + RC_OK + ); + assert_eq!(engine.initialize(), RC_OK); + + let (_, result) = engine.invoke( + "identity.resolve", + PAYLOAD_IDENTITY, + &identity_payload(&signer.token(issuer)), + ); + assert_eq!( + result.expect("result").violation_code(), + Some("auth.jwks_unavailable") + ); + assert_eq!(server.hits(JWKS_PATH), 0); +} + +// --------------------------------------------------------------------- +// delegator-oauth +// --------------------------------------------------------------------- + +#[test] +fn oauth_token_exchange_to_a_private_endpoint_is_egress_denied() { + let server = MockServer::start(); + server.set( + "/oauth/token", + 200, + r#"{"access_token":"minted","token_type":"Bearer","expires_in":300}"#, + ); + let yaml = format!( + r#" +engine_settings: + dispatch: hooks +plugins: + - name: oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [perform_http, read_inbound_credentials, write_delegated_tokens] + config: + token_endpoint: "{}" + insecure_http: true + client_id: gateway + client_secret_source: {{ kind: literal, secret: test-secret }} + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + timeout_seconds: 2 + default_outbound_header: Authorization +"#, + server.url("/oauth/token") + ); + + let engine = Engine::installed(0); + assert_eq!(engine.load(&yaml), RC_OK); + assert_eq!(engine.initialize(), RC_OK); + + // The FFI has no delegation payload type, so drive the hook on the + // FFI-built engine directly. + let payload = DelegationPayload::new("caller-bearer-token", "workday-api") + .with_target_audience("workday-api"); + let (result, _bg) = block_on(engine.inner().engine.invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + )); + assert!(!result.continue_processing); + let violation = result.violation.expect("a denial"); + assert_eq!(violation.code, "delegation.egress_denied", "{violation:?}"); + assert_eq!(server.hits("/oauth/token"), 0); +} + +// --------------------------------------------------------------------- +// Vault secret refresh +// --------------------------------------------------------------------- + +const VAULT_LOGIN: &str = "/v1/auth/approle/login"; +const VAULT_KV: &str = "/v1/secret/data/app"; + +fn vault_server(password: &str) -> MockServer { + let server = MockServer::start(); + server.set( + VAULT_LOGIN, + 200, + r#"{"auth":{"client_token":"hvs.test","lease_duration":3600,"renewable":true}}"#, + ); + set_password(&server, password); + server +} + +fn set_password(server: &MockServer, password: &str) { + server.set( + VAULT_KV, + 200, + &json!({ "data": { "data": { "password": password } } }).to_string(), + ); +} + +fn vault_config(address: &str) -> String { + format!( + r#" +engine_settings: + dispatch: hooks +secrets: + providers: + vault-dev: + kind: vault + address: "{address}" + insecure_http: true + allow_insecure_literal: true + auth: + method: approle + role_id: role-id + secret_id: {{ literal: secret-id }} + values: + db_password: + provider: vault-dev + ref: "secret/app#password" +"# + ) +} + +#[derive(Debug, serde::Deserialize)] +struct Report { + updated: Vec, + unchanged: u64, + failed: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct Failure { + secret: String, + provider: String, + kind: String, + message: String, +} + +fn refresh(engine: &Engine) -> Report { + let mut out: *mut u8 = ptr::null_mut(); + let mut len: c_int = 0; + unsafe { + assert_eq!(cpex_refresh_secrets(engine.0, &mut out, &mut len), RC_OK); + let report = rmp_serde::from_slice(std::slice::from_raw_parts(out, len as usize)) + .expect("decode refresh report"); + cpex_free_bytes(out, len); + report + } +} + +fn last_success(engine: &Engine, provider: &str) -> i64 { + let mut ms: i64 = -1; + let rc = unsafe { + cpex_secrets_last_success( + engine.0, + provider.as_ptr() as *const c_char, + provider.len() as c_int, + &mut ms, + ) + }; + assert_eq!(rc, RC_OK); + ms +} + +#[test] +fn vault_refresh_picks_up_a_rotated_value() { + let server = vault_server("first"); + let engine = Engine::installed(CPEX_APL_ALLOW_PRIVATE_DESTINATIONS); + assert_eq!(engine.load(&vault_config(&server.origin())), RC_OK); + assert_eq!(engine.initialize(), RC_OK); + assert_eq!(server.hits(VAULT_KV), 1, "initialize read the value"); + let at_init = last_success(&engine, "vault-dev"); + assert!(at_init > 0, "initialization counts as a success"); + assert_eq!(last_success(&engine, "not-declared"), 0); + + let report = refresh(&engine); + assert!(report.updated.is_empty(), "{report:?}"); + assert_eq!(report.unchanged, 1); + assert!(report.failed.is_empty()); + + set_password(&server, "rotated"); + // Let the clock move so the new stamp is strictly later. + block_on(async { tokio::time::sleep(Duration::from_millis(5)).await }); + let report = refresh(&engine); + assert_eq!(report.updated, vec!["db_password".to_owned()], "{report:?}"); + assert_eq!(report.unchanged, 0); + assert!(report.failed.is_empty()); + let after_rotation = last_success(&engine, "vault-dev"); + assert!(after_rotation > at_init, "{after_rotation} > {at_init}"); + + // A backend outage keeps the last-good value, names the failure, and + // retains the last-success stamp for staleness checks. + server.set(VAULT_KV, 503, r#"{"errors":["sealed"]}"#); + let report = refresh(&engine); + assert_eq!(report.failed.len(), 1, "{report:?}"); + let failure = &report.failed[0]; + assert_eq!(failure.secret, "db_password"); + assert_eq!(failure.provider, "vault-dev"); + assert_eq!(failure.kind, "backend", "{}", failure.message); + assert!( + !failure.message.contains("rotated"), + "no secret in the message" + ); + assert_eq!(last_success(&engine, "vault-dev"), after_rotation); + + // Recovery stamps it again. + set_password(&server, "rotated"); + let report = refresh(&engine); + assert!(report.failed.is_empty(), "{report:?}"); + assert!(last_success(&engine, "vault-dev") >= after_rotation); +} + +#[test] +fn vault_on_loopback_is_refused_without_the_flag() { + let server = vault_server("first"); + let engine = Engine::installed(0); + assert_eq!(engine.load(&vault_config(&server.origin())), RC_OK); + assert_eq!( + engine.initialize(), + RC_PIPELINE_ERROR, + "the secret cannot resolve" + ); + assert_eq!(server.hits(VAULT_LOGIN), 0); +} + +#[test] +fn refresh_without_secrets_returns_an_empty_report() { + let engine = Engine::installed(0); + assert_eq!(engine.initialize(), RC_OK); + let report = refresh(&engine); + assert!(report.updated.is_empty() && report.failed.is_empty()); + assert_eq!(report.unchanged, 0); + assert_eq!(last_success(&engine, "vault-dev"), 0); +} + +#[test] +fn secret_entry_points_validate_their_arguments() { + let mut out: *mut u8 = ptr::null_mut(); + let mut len: c_int = 0; + let mut ms: i64 = 0; + let name = "vault-dev"; + unsafe { + assert_eq!( + cpex_refresh_secrets(ptr::null(), &mut out, &mut len), + RC_INVALID_HANDLE + ); + assert!(out.is_null() && len == 0); + assert_eq!( + cpex_secrets_last_success(ptr::null(), name.as_ptr() as *const c_char, 9, &mut ms), + RC_INVALID_HANDLE + ); + let engine = Engine::installed(0); + assert_eq!( + cpex_refresh_secrets(engine.0, ptr::null_mut(), &mut len), + RC_INVALID_INPUT + ); + assert_eq!( + cpex_secrets_last_success(engine.0, ptr::null(), 0, &mut ms), + RC_INVALID_INPUT + ); + assert_eq!( + cpex_secrets_last_success(engine.0, name.as_ptr() as *const c_char, 9, ptr::null_mut()), + RC_INVALID_INPUT + ); + } +} diff --git a/crates/cpex-ffi/tests/fixtures/wire_compat.json b/crates/cpex-ffi/tests/fixtures/wire_compat.json new file mode 100644 index 00000000..3bd7a88c --- /dev/null +++ b/crates/cpex-ffi/tests/fixtures/wire_compat.json @@ -0,0 +1,63 @@ +{ + "description": "Plugin kinds, PDP kinds, session store kinds, secret provider kinds and hook names that the CPEX bindings (C FFI and Python wheel) expose after installing builtins. A new PPE builtin is one edit here. Kinds listed under absent_plugin_kinds must not resolve.", + "plugin_kinds": [ + "delegator/oauth", + "delegator/vault", + "elicitation/ciba", + "identity/api-key", + "identity/jwt" + ], + "absent_plugin_kinds": [ + "audit/logger", + "quota/limitador", + "validator/pii-scan" + ], + "pdp_kinds": [ + "cedar-direct", + "cel", + "opa" + ], + "session_store_kinds": [ + "valkey" + ], + "secret_provider_kinds": [ + "env", + "file", + "vault" + ], + "hooks": { + "cmf": [ + "cmf.llm_input", + "cmf.llm_output", + "cmf.prompt_post_invoke", + "cmf.prompt_pre_invoke", + "cmf.resource_post_fetch", + "cmf.resource_pre_fetch", + "cmf.tool_post_invoke", + "cmf.tool_pre_invoke" + ], + "http": [ + "http.request", + "http.response" + ], + "identity": [ + "identity.resolve" + ], + "delegation": [ + "token.delegate" + ], + "elicitation": [ + "elicit" + ] + }, + "removed_hooks": [ + "identity_resolve", + "prompt_post_fetch", + "prompt_pre_fetch", + "resource_post_fetch", + "resource_pre_fetch", + "token_delegate", + "tool_post_invoke", + "tool_pre_invoke" + ] +} diff --git a/crates/cpex-ffi/tests/wire_compat.rs b/crates/cpex-ffi/tests/wire_compat.rs new file mode 100644 index 00000000..bff160e3 --- /dev/null +++ b/crates/cpex-ffi/tests/wire_compat.rs @@ -0,0 +1,191 @@ +// Location: ./crates/cpex-ffi/tests/wire_compat.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Pins the plugin kinds, PDP kinds, session store kinds, secret provider +// kinds and hook names an FFI host gets after `cpex_apl_install`, against +// `tests/fixtures/wire_compat.json`. The Python bindings read the same +// fixture, so a new PPE builtin is one edit there. + +mod common; + +use std::collections::BTreeSet; +use std::sync::Arc; + +use praxis_policy_core::engine::PolicyEngine; + +use common::{Engine, block_on}; + +#[derive(serde::Deserialize)] +struct Fixture { + plugin_kinds: BTreeSet, + absent_plugin_kinds: BTreeSet, + pdp_kinds: BTreeSet, + session_store_kinds: BTreeSet, + secret_provider_kinds: BTreeSet, + hooks: std::collections::BTreeMap>, + removed_hooks: BTreeSet, +} + +fn fixture() -> Fixture { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/wire_compat.json" + ); + let text = std::fs::read_to_string(path).expect("read wire_compat.json"); + serde_json::from_str(&text).expect("parse wire_compat.json") +} + +fn set, S: Into>(items: I) -> BTreeSet { + items.into_iter().map(Into::into).collect() +} + +/// The load error for a one-plugin config naming `kind`, or empty. +fn load_error(engine: &Arc, kind: &str, hook: &str) -> String { + let yaml = format!( + "engine_settings:\n dispatch: hooks\nplugins:\n - name: probe\n kind: {kind}\n hooks: [{hook}]\n" + ); + engine + .load_config_yaml(&yaml) + .err() + .map(|e| e.to_string()) + .unwrap_or_default() +} + +#[test] +fn plugin_kinds_match_the_fixture() { + let fx = fixture(); + let engine = Engine::installed(0); + let engine = &engine.inner().engine; + + for kind in &fx.plugin_kinds { + let err = load_error(engine, kind, "identity.resolve"); + assert!( + !err.contains("no factory registered"), + "{kind} must resolve; got: {err}" + ); + } + for kind in &fx.absent_plugin_kinds { + let err = load_error(engine, kind, "identity.resolve"); + assert!( + err.contains("no factory registered"), + "{kind} must not resolve; got: {err}" + ); + } + + // The probe above cannot see an extra kind, so also pin the set the + // bundled facade exports. + let exported = set([ + cpex::JWT_KIND, + cpex::API_KEY_KIND, + cpex::OAUTH_KIND, + cpex::VAULT_DELEGATOR_KIND, + cpex::CIBA_KIND, + ]); + assert_eq!(exported, fx.plugin_kinds); + for kind in ["opa", "ciba", "api-key"] { + let named = fx.pdp_kinds.contains(kind) + || fx + .plugin_kinds + .iter() + .any(|k| k.ends_with(&format!("/{kind}"))); + assert!(named, "{kind} must be in the bundled set"); + } +} + +#[test] +fn pdp_and_session_store_kinds_match_the_fixture() { + let fx = fixture(); + let pdps = set(cpex::builtin_pdp_factories() + .iter() + .map(|f| f.kind().to_owned())); + assert_eq!(pdps, fx.pdp_kinds); + let stores = set(cpex::builtin_session_store_factories() + .iter() + .map(|f| f.kind().to_owned())); + assert_eq!(stores, fx.session_store_kinds); +} + +/// The engine names its registered secret provider kinds when a document +/// asks for one it lacks. +#[test] +fn secret_provider_kinds_match_the_fixture() { + let fx = fixture(); + let engine = Engine::installed(0); + let yaml = r#" +engine_settings: + dispatch: hooks +secrets: + providers: + probe: + kind: no-such-backend + values: + probe_value: + provider: probe + ref: anything +"#; + assert_eq!(engine.load(yaml), cpex_ffi::RC_OK); + let err = block_on(engine.inner().engine.initialize()) + .expect_err("an unregistered provider kind fails initialize") + .to_string(); + let listed = err + .split("registered kinds are [") + .nth(1) + .and_then(|rest| rest.split(']').next()) + .unwrap_or_else(|| panic!("error must list registered kinds: {err}")); + assert_eq!(set(listed.split(", ")), fx.secret_provider_kinds); +} + +#[test] +fn hook_names_match_the_fixture() { + let fx = fixture(); + let pinned: BTreeSet = fx.hooks.values().flatten().cloned().collect(); + let builtin = set(praxis_policy_core::hooks::builtin_hook_types() + .iter() + .map(|h| h.as_str().to_owned())); + assert_eq!(builtin, pinned); + for family in ["cmf", "identity", "delegation", "elicitation"] { + assert!( + fx.hooks.contains_key(family), + "fixture lists {family} hooks" + ); + } + + let engine = Engine::installed(0); + let engine = &engine.inner().engine; + for hook in &pinned { + let err = load_error(engine, "identity/jwt", hook); + assert!( + !err.contains("unknown hook"), + "{hook} must load; got: {err}" + ); + } + for hook in &fx.removed_hooks { + let err = load_error(engine, "identity/jwt", hook); + assert!( + err.contains("unknown hook"), + "{hook} must be rejected; got: {err}" + ); + } +} + +/// Every removed hook in the fixture is refused at invoke time, through the +/// facade's table, and its replacement is a pinned hook name. +#[test] +fn removed_hooks_are_refused_at_invoke() { + let fx = fixture(); + let pinned: BTreeSet<&String> = fx.hooks.values().flatten().collect(); + let engine = Engine::installed(0); + let payload = rmp_serde::to_vec_named(&serde_json::json!({})).expect("encode"); + for hook in &fx.removed_hooks { + let replacement = cpex::host::removed_hook_replacement(hook) + .unwrap_or_else(|| panic!("{hook} is missing from cpex::host::REMOVED_HOOKS")); + assert!( + pinned.contains(&replacement.to_owned()), + "{hook} maps to {replacement}, which is not a pinned hook" + ); + let (rc, _) = engine.invoke(hook, cpex_ffi::PAYLOAD_GENERIC, &payload); + assert_eq!(rc, cpex_ffi::RC_INVALID_INPUT, "{hook}"); + } +} diff --git a/crates/cpex-hosts-python/Cargo.toml b/crates/cpex-hosts-python/Cargo.toml index 2ece047c..bcce70cc 100644 --- a/crates/cpex-hosts-python/Cargo.toml +++ b/crates/cpex-hosts-python/Cargo.toml @@ -25,20 +25,22 @@ categories.workspace = true rust-version.workspace = true [dependencies] -cpex-core = { workspace = true } +praxis-policy-core = { workspace = true } async-trait = { workspace = true } +# Expiry on a token a Python delegation plugin mints (`credential_payloads`). +chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } # Version-pinned rather than workspace-inherited, matching how the rest of the -# tree declares it (crates/apl-cpex, builtins/session/valkey). Used by `venv` +# tree declares it. Used by `venv` # to fingerprint a requirement set into its cache directory name. # # No `zeroize` here, deliberately, even though this crate handles plaintext -# credentials. `cpex-core` and builtins/plugins/delegator-oauth hold a secret +# credentials. `praxis-policy-core` and the OAuth delegator hold a secret # in memory for its lifetime, so `Zeroizing` there wipes the one authoritative # copy. This host instead *forwards* the plaintext: `credentials` reads the # token out of core's `Zeroizing` field and it is immediately copied into a @@ -61,8 +63,11 @@ tokio = { workspace = true, features = ["process", "io-util"] } testing = [] [dev-dependencies] -chrono = { workspace = true } serde_yaml = { workspace = true } +# The APL runtime, so `tests/acceptance_e2e.rs` can drive a route's +# `require(...)` and `delegate(...)` steps through the real engine. A dev-only +# edge: the facade depends on this crate, never the other way round. +praxis-policy = { workspace = true } tokio = { workspace = true, features = ["process", "io-util", "macros", "rt", "rt-multi-thread"] } # Self-dependency with the test-only feature on, so `tests/*.rs` see diff --git a/crates/cpex-hosts-python/README.md b/crates/cpex-hosts-python/README.md index 9bb592c9..e61da98d 100644 --- a/crates/cpex-hosts-python/README.md +++ b/crates/cpex-hosts-python/README.md @@ -1,6 +1,6 @@ # cpex-hosts-python -Runs existing Python CPEX plugins out-of-process from the Rust `PluginManager`. +Runs existing Python CPEX plugins out-of-process from the Rust `PolicyEngine`. Each plugin gets its own cached virtualenv and a long-lived `worker.py` subprocess, spoken to over newline-delimited JSON on stdio — the same protocol the Python CLI already uses, so a plugin that works there works here unchanged. @@ -20,25 +20,31 @@ framing. Register the factory before loading config: ```rust -use cpex_core::factory::PluginFactoryRegistry; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::factory::PluginFactoryRegistry; use cpex_hosts_python::{IsolatedVenvFactory, KIND}; let mut factories = PluginFactoryRegistry::new(); factories.register(KIND, Box::new(IsolatedVenvFactory)); -let manager = PluginManager::from_config(config, &factories)?; +let engine = PolicyEngine::from_config(config, &factories)?; ``` +Through the `cpex` facade, enable the `python-host` feature and register +`cpex::IsolatedVenvFactory` under `cpex::ISOLATED_VENV_KIND`. + ## Config schema Host-specific settings live in the plugin entry's opaque `config:` map, because `PluginConfig` has no fields for them: ```yaml +engine_settings: + dispatch: hooks # or name the plugin from a policy step plugins: - name: pii-filter kind: isolated_venv - hooks: [tool_pre_invoke] + hooks: [cmf.tool_pre_invoke] config: # Required — the fully-qualified Python class. class_name: my_pkg.filters.PiiFilter @@ -67,11 +73,11 @@ directory — as the worker's `sys.path` entry and as the directory the venv is built under. There is no config key for it: - a `plugin_dirs:` key inside a plugin's `config:` block is **ignored** (the - factory logs a warning naming the plugin), and -- the top-level `plugin_dirs:` YAML key is **ignored** too — `cpex-core` parses - it, warns, and discards it. - -Both are ignored rather than rejected, so a config carrying either still loads. + factory logs a warning naming the plugin), so a config carrying it still + loads, and +- the top-level `plugin_dirs:` YAML key is **rejected**: PPE's config loader + refuses unknown document keys. `cpex plugin install` still writes that key, so + remove it from an installer-written `config.yaml`. This is deliberate. `cpex plugin install` writes the plugin into `/plugins/` and builds its venv there, so both sides already agree @@ -83,19 +89,120 @@ host), so the resolved directory is importable by construction. ## Hooks and payloads -Both hook families work. The `cmf.` prefix selects the CMF message payload; -each legacy name maps to its own typed payload; anything unrecognized falls -back to a generic JSON payload. +Hook names follow PPE. The host looks each declared name up in a fixed table +that picks the payload type and the name the Python worker knows the hook by. +The Python framework still registers the older spellings, so the host +translates on the wire (the task's `hook_type` and the config's `hooks` list). -| Hook | Payload | +| Declared hook (PPE) | Payload | Worker hook name | +|---|---|---| +| `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` | `MessagePayload` | same | +| `cmf.llm_input` / `cmf.llm_output` | `MessagePayload` | same | +| `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` | `MessagePayload` | `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch` | +| `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` | `MessagePayload` | same | +| `identity.resolve` | PPE's `IdentityPayload` (or the legacy `IdentityResolvePayload`) | `identity_resolve` | +| `token.delegate` | PPE's `DelegationPayload` (or the legacy `TokenDelegatePayload`) | `token_delegate` | +| anything else | `GenericPayload` | unchanged | + +### Identity and delegation payloads + +PPE dispatches `identity.resolve` with `praxis_policy_core::identity::IdentityPayload` +and `token.delegate` with `delegation::DelegationPayload`; its FFI, its Python +bindings and the APL `delegate(...)` step all build those. The host sends each +to the worker as the matching Python model and maps the plugin's +`IdentityResult` / `DelegationResult` back onto a clone of the dispatched +payload, so PPE's `IdentityPayload::from_pipeline_result` and +`DelegationPayload::from_pipeline_result` find it. + +| Direction | What happens | |---|---| -| `cmf.*` | `MessagePayload` | -| `tool_pre_invoke` / `tool_post_invoke` | `ToolPreInvokePayload` / `ToolPostInvokePayload` | -| `prompt_pre_fetch` / `prompt_post_fetch` | `PromptPreFetchPayload` / `PromptPostFetchPayload` | -| `resource_pre_fetch` / `resource_post_fetch` | `ResourcePreFetchPayload` / `ResourcePostFetchPayload` | -| `identity_resolve` | `IdentityResolvePayload` | -| `token_delegate` | `TokenDelegatePayload` | -| anything else | `GenericPayload` | +| `IdentityPayload` out | `source`, `client_host`, `client_port`, and an empty public `headers` map. Raw header values have no default allowlist; a name alone cannot prove a value is safe. `raw_token` is sent empty. | +| `IdentityResult` back | `subject` and `delegation` are written, `raw_claims` merged. `rejected: true` becomes a deny (`auth.rejected`, with `reject_reason` and `reject_status`). Echoed input fields are ignored, as PPE treats them as canonical. Any other field, or a field PPE's types cannot hold, fails closed. | +| `DelegationPayload` out | Target name, type, audience, permissions, trust domain, enforcement and attenuation. `bearer_token` is not sent. A delegation with an RFC 8693 actor, or for a subject other than `user`, fails closed: the Python model cannot carry either. | +| `DelegationResult` back | `delegated_token` becomes PPE's `delegated_token` for the target audience and required permissions, expiring after `cache_ttl` (60 seconds when unset). `forwarded_headers` may only name the one header carrying the minted token; anything else fails closed. `delegation_update` is written when it is not empty. | + +The raw tokens on both payloads reach a plugin only through the +capability-gated `credential` object (see [Credentials](#credentials)). + +The `cmf.*` rows apply to a worker that claims the `cmf_hooks` feature in the +spawn-time `capabilities` handshake. No released worker does yet, so in +practice the `cmf.*` hooks go through the legacy payload shim below. + +### CMF hooks on workers without `cmf_hooks` (the legacy payload shim) + +The released 0.1.x Python SDK registers no `cmf.*` hook in the worker, and its +plugins implement `tool_pre_invoke` and friends with the typed payloads. So for +any worker that does not claim `cmf_hooks`, the host translates each tool, +prompt and resource `cmf.*` hook into the legacy hook and payload the worker +already knows, and maps the result back. A 0.1.x plugin keeps working with no +SDK release: declare `cmf.tool_pre_invoke` and implement `tool_pre_invoke`. + +`cmf.llm_input` and `cmf.llm_output` have no legacy hook. A plugin declaring +either one fails initialization unless the worker reports `cmf_hooks`. + +| PPE hook | Built from (CMF part) | Legacy hook and payload sent | Mapped back onto the part | +|---|---|---|---| +| `cmf.tool_pre_invoke` | `tool_call` | `tool_pre_invoke`: `{name, args}` | `name`, `arguments` | +| `cmf.tool_post_invoke` | `tool_result` | `tool_post_invoke`: `{name: tool_name, result: content}` | `tool_name`, `content` | +| `cmf.prompt_pre_invoke` | `prompt_request` | `prompt_pre_fetch`: `{prompt_id: name, args}` | `name`, `arguments` | +| `cmf.prompt_post_invoke` | `prompt_result` | `prompt_post_fetch`: `{prompt_id: prompt_name, result: {messages: [{role, content: {type: "text", text}}], description: null}}` | `prompt_name`, `messages` (role and text; a message keeps its `channel`) | +| `cmf.resource_pre_fetch` | `resource` or `resource_ref` | `resource_pre_fetch`: `{uri}` | `uri` | +| `cmf.resource_post_fetch` | `resource` | `resource_post_fetch`: `{uri, content: {type: "resource", id, uri, mime_type, text}}` | `uri`, `content` (text), `mime_type`; `size_bytes` is recomputed when the text changes | + +The worker's `continue_processing` and `violation` pass through unchanged. A +modified legacy payload replaces only the fields in the last column; every +other field of the message and of the part is kept as dispatched. A modified +tool result that sets `"isError": true` also sets the part's `is_error`. + +The shim fails closed, with a `legacy_shim_unsupported` error naming the hook +and the plugin, rather than allowing or skipping: + +- a message without exactly one content part of the kind in the second column + (for example a `cmf.tool_pre_invoke` message with no tool call, or with two); +- on a post hook (`cmf.tool_post_invoke`, `cmf.prompt_post_invoke`, + `cmf.resource_post_fetch`), a message holding any other content part that + carries text or data (a text or thinking part with text, a media part, + another tool or resource part): the plugin never sees it, so a filter would + pass it through unredacted. Empty text and thinking parts are kept. On a pre + hook such sibling parts pass through unchanged: the plugin authorizes the + part it sees, and the rest of the message goes back as dispatched; +- a prompt argument that is not a string (the legacy `args` is + `dict[str, str]`); +- a qualifier a plugin that authorizes by bare name or URI cannot see: a tool + call `namespace`, a prompt request `server_id`, or a resource `version` + (each when non-empty); +- a tool result with `is_error` set whose `content` does not carry + `"isError": true`; +- a prompt result with rendered `content`, an `error_message`, or `is_error` + set (the legacy result holds only messages, so a plugin could not redact + that text); +- a prompt result message that is not a single text part; +- a resource with a binary `blob`, or a `resource_ref` on post-fetch; +- a modified payload that sets something the message cannot hold: tool + `headers`, resource `metadata`, a prompt result `description`, a resource + `content.blob`, a changed resource `content.id`, a `content.uri` other than + the original or the returned `uri`, or non-text prompt message content; +- a modified payload that would leave stale data behind: a cleared `isError` + on an error tool result, or a pre-fetch URI rewrite on a resource that + already carries `content`, `blob`, `mime_type` or `size_bytes` from the + original URI. + +The field-by-field decision for every CMF part field is the table at the top +of `src/legacy/cmf_shim.rs`. + +The shim lives in `src/legacy/cmf_shim.rs`. It goes away once every supported +worker claims `cmf_hooks`. + +The unprefixed `tool_pre_invoke`, `tool_post_invoke`, `prompt_pre_fetch`, +`prompt_post_fetch`, `resource_pre_fetch` and `resource_post_fetch` keep their +typed payloads (`ToolPreInvokePayload` and so on). PPE does not dispatch them +and its config loader rejects them, so they only apply to a host that registers +hook metadata for them (`praxis_policy_core::hooks::register_hook_metadata`) +before loading config. The unprefixed `identity_resolve` and `token_delegate` +are not in the table: they get the generic payload and no raw credential. + +The adapter reports the hook family of each declared name (`cmf` for the CMF +hooks), so the engine's family check at registration accepts it. A plugin error is returned to the executor, which applies the plugin's configured `on_error` policy (fail / ignore / disable). This host does not @@ -109,13 +216,16 @@ serialized onto the task under an `extensions` field, so a 3-arg equivalent would. Slot visibility comes from the filtered view — this host does not re-derive capability filtering. -Two rules apply in **both** directions: +The two directions have different rules: - `raw_credentials` never rides this channel. Raw tokens travel the capability-gated `credential` DTO instead (see below). -- `Authorization`, `Cookie`, and `X-API-Key` are stripped from - `http.request_headers` and `http.response_headers` (spec §3.5), matched - case-insensitively. +- Outbound `http.request_headers` and `http.response_headers` are empty by + default. A raw header may be added only with a value validator and a + documented use. Request-line fields still cross. +- On worker return, known credential header names are removed + case-insensitively. Other header edits still require `write_headers` and + pass through the executor's guarded merge. A plugin's returned extensions come back on the response's `modified_extensions` field — the field the Python `PluginResult` model already @@ -141,7 +251,7 @@ touched `custom`. Write authority comes from the `WriteToken` the executor mints per plugin from its declared capabilities and carries on the inbound view. `WriteToken::new()` is -`pub(crate)` to `cpex-core`, so this host cannot mint one — a gated write with no +`pub(crate)` to `praxis-policy-core`, so this host cannot mint one. A gated write with no token behind it is dropped rather than honored. ## Credentials @@ -150,7 +260,7 @@ The token fields on the framework's credential types are `#[serde(skip)]`, so no generic serialization of an `Extensions` carries token bytes — which would leave identity resolvers and token delegators unable to work out-of-process. This host adds a narrow, capability-gated exception on a separate channel: raw credential -material **does** reach the worker process, deliberately. `cpex-core`'s +material **does** reach the worker process, deliberately. `praxis-policy-core`'s `RawCredentialsExtension` module docs state the same boundary from the other side. @@ -160,7 +270,7 @@ A plugin declares what it needs: capabilities: [read_inbound_credentials] # or read_delegated_tokens ``` -Only `identity_resolve` and `token_delegate` are eligible — they are the only +Only `identity.resolve` and `token.delegate` are eligible: they are the only hooks whose Python payload models a raw token (`IdentityPayload.raw_token`, `DelegationPayload.bearer_token`). For a declaring plugin on one of those hooks, the host attaches a dedicated `credential` object to the task JSON, built by @@ -168,6 +278,19 @@ reading the in-memory token directly. Production credential types keep their serde guard and are never serialized; the FFI, Python-bindings, and audit paths are untouched. +Where the token comes from depends on the payload: + +| Hook and payload | Token | Capability | +|---|---|---| +| `identity.resolve`, PPE `IdentityPayload` | `raw_token`, else the source header (a `Bearer ` prefix stripped). The gated object also carries the full header map, which the worker scrubs of the token. | `read_inbound_credentials` | +| `token.delegate`, PPE `DelegationPayload` | `bearer_token`, the caller's inbound credential | `read_inbound_credentials`, as PPE's own OAuth delegator declares | +| `identity.resolve`, legacy payload | the inbound token in `raw_credentials` | `read_inbound_credentials` | +| `token.delegate`, legacy payload | the delegated token in `raw_credentials` for the target audience | `read_delegated_tokens` | + +A plugin on the same hook that declared nothing sees no token or raw request +header values. A plugin with `read_inbound_credentials` receives the full map +only through the gated credential object. + **Fail closed.** A plugin that declared nothing gets no `credential` field and no token material. A plugin that declared a capability the request cannot honor gets an error — not a silent no-token dispatch, which a resolver could read as @@ -224,16 +347,23 @@ reason** when one is absent rather than failing: ```bash CPEX_PYTHON_SOURCE=/path/to/cpex-python \ - cargo test -p cpex-hosts-python --features testing --test isolated_venv_e2e --test credential_e2e + cargo test -p cpex-hosts-python --features testing --test isolated_venv_e2e \ + --test credential_e2e --test acceptance_e2e -- --ignored ``` +`make test-python-e2e CPEX_PYTHON_SOURCE=...` runs the same suites with skips +turned into failures. `acceptance_e2e` loads a policy with Python plugins on +`identity.resolve` and `token.delegate` plus an APL route, and drives PPE's own +`IdentityPayload` and the route's `delegate(...)` step through `PolicyEngine`. + Two environmental caveats, both upstream of this crate: - 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 - than silently allowed. +- The 0.1.x Python worker registers no `cmf.*` hooks and does not report the + `cmf_hooks` feature, so the host translates `cmf.*` tool, prompt and resource + 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. diff --git a/crates/cpex-hosts-python/quickstart.md b/crates/cpex-hosts-python/quickstart.md index e21dcbc0..4ee1f3da 100644 --- a/crates/cpex-hosts-python/quickstart.md +++ b/crates/cpex-hosts-python/quickstart.md @@ -1,4 +1,4 @@ -# quickstart instructions - Installing and runing existing Python CPEX plugins out-of-process from the Rust PluginManager +# quickstart instructions - Installing and runing existing Python CPEX plugins out-of-process from the Rust PolicyEngine ## First steps - create a folder to setup CPEX (Rust) + (Pythyon) @@ -55,24 +55,51 @@ cargo add serde_json ``` +## Adapting the installer-written config + +`cpex plugin install` writes `plugins/config.yaml` in the pre-PPE shape. Make +three edits before loading it: + +- Delete the top-level `plugin_dirs:` key. PPE rejects it; the host always uses + `plugins/` at the project root. +- Add `engine_settings:` with `dispatch: hooks`, so each plugin fires at the + hooks it declares without a `routes:` or `authorization:` block. +- Rename the hooks in any `hooks:` list to PPE's names: `tool_pre_invoke` to + `cmf.tool_pre_invoke`, `identity_resolve` to `identity.resolve`, and + `token_delegate` to `token.delegate`. The host translates them back to the + names the Python worker knows. + +The plugin itself needs no change. The 0.1.x worker registers no `cmf.*` hooks, +so the host serves `cmf.tool_pre_invoke` to it through the legacy payload shim: +the plugin's `tool_pre_invoke` receives the usual `ToolPreInvokePayload` +(`name` plus `args`), and a modified payload maps back onto the CMF message. +See the README's "legacy payload shim" section for every hook it covers. Text +beside the tool call passes through unchanged on this pre hook. On a post hook +(`cmf.tool_post_invoke` and the like) a message with text beside the result +fails closed, since the legacy plugin would never see that text. + +An identity or delegation plugin also needs no change. On `identity.resolve` +and `token.delegate` it receives the `IdentityPayload` / `DelegationPayload` it +already reads, and its `IdentityResult` / `DelegationResult` maps back onto +PPE's payload. To receive the raw token it declares `read_inbound_credentials` +on either hook (the bearer token a delegator exchanges is the caller's inbound +credential). + ## Example driver program (replaces code-review/python-tests/src/main.rs) ```rust use std::path::{PathBuf}; use std::sync::Arc; use std::collections::HashMap; -use cpex::cpex_core::manager::PluginManager; -use cpex::cpex_core::factory::PluginFactoryRegistry; -use cpex::cpex_core::config::parse_config; -use cpex::cpex_core::hooks::types::hook_names; -use cpex::cpex_core::error::PluginError; -use cpex::cpex_core::extensions::{ +use cpex::PolicyEngine; +use cpex::praxis_policy_core::factory::PluginFactoryRegistry; +use cpex::praxis_policy_core::config::parse_config; +use cpex::praxis_policy_core::error::PluginError; +use cpex::praxis_policy_core::extensions::{ AgentExtension, Extensions, HttpExtension, RequestExtension, SecurityExtension, }; use cpex::cpex_hosts_python::{IsolatedVenvFactory, KIND}; -use cpex::cpex_hosts_python::legacy::{ - IdentityResolvePayload, TokenDelegatePayload, ToolPreInvokePayload, -}; +use cpex::praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; /// Marker `TestPlugin.identity_resolve` writes when `raw_token` arrives with a /// non-empty secret value — evidence the field survived the wire as a populated @@ -91,8 +118,11 @@ const INBOUND_LABEL: &str = "CONFIG-E2E-LABEL-4f18"; /// absence from the serialized task, so it has to be unique enough that a match /// anywhere in the JSON is conclusive. const SENSITIVE_HEADER_VALUE: &str = "Bearer config-e2e-must-not-travel"; -/// The repository root — `plugins/config.yaml` and the installed plugin's -/// `plugin_dirs` are both relative to it. +/// The PPE hook the driver dispatches. The installed plugin implements the +/// legacy `tool_pre_invoke`, which the host reaches through the shim. +const TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; + +/// The project root. `plugins/config.yaml` and the installed plugin live under it. fn repo_root() -> PathBuf { // CARGO_MANIFEST_DIR = ./ PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -145,25 +175,33 @@ async fn main() -> Result<(), Box> { let yaml = std::fs::read_to_string(&config_path).map_err(|e| PluginError::Config { message: format!("failed to read config file '{}': {}", config_path.display(), e), })?; - let cpex_config = parse_config(&yaml).unwrap(); + let config = parse_config(&yaml)?; let mut factories = PluginFactoryRegistry::new(); factories.register(KIND, Box::new(IsolatedVenvFactory)); - let manager = PluginManager::from_config(cpex_config, &factories).unwrap(); + let manager = PolicyEngine::from_config(config, &factories)?; manager .initialize() .await .expect("the venv resolves and the worker starts"); - // `tool_pre_invoke` carries the native Pydantic shape the Python plugin - // expects — `name` plus `args` — not the generic wrapper. - let payload = ToolPreInvokePayload { - name: "test_tool".to_string(), - args: Some(HashMap::from([( - "query".to_string(), - serde_json::json!("hello world"), - )])), - headers: None, + // A CMF tool call. The host turns it into the `name` plus `args` payload + // the plugin's legacy `tool_pre_invoke` expects. + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-1".to_string(), + name: "test_tool".to_string(), + arguments: HashMap::from([( + "query".to_string(), + serde_json::json!("hello world"), + )]), + namespace: None, + }, + }], + ), }; // Populated extensions rather than `Extensions::default()`. The default is @@ -176,7 +214,7 @@ async fn main() -> Result<(), Box> { let (result, _background) = manager .invoke_by_name( - hook_names::TOOL_PRE_INVOKE, + TOOL_PRE_INVOKE, Box::new(payload), inbound.clone(), None, @@ -185,7 +223,10 @@ async fn main() -> Result<(), Box> { manager.shutdown().await; - println!("Hello, CPEX!"); + println!( + "allowed={} errors={:?} violation={:?}", + result.continue_processing, result.errors, result.violation + ); Ok(()) } ``` diff --git a/crates/cpex-hosts-python/src/conversion.rs b/crates/cpex-hosts-python/src/conversion.rs index 530a88f6..ec0b2cef 100644 --- a/crates/cpex-hosts-python/src/conversion.rs +++ b/crates/cpex-hosts-python/src/conversion.rs @@ -19,13 +19,21 @@ // single `P: PluginPayload`. Outbound, serialization is a downcast chain // (mirroring `cpex-ffi`'s ordering) because the concrete type arrives erased. // Inbound, the hook *name* selects which typed payload a `modified_payload` -// deserializes into — a `PayloadKind` per hook name, with `cmf.` routing to -// `MessagePayload` and unrecognized names to the generic payload. - -use cpex_core::cmf::MessagePayload; -use cpex_core::error::PluginViolation; -use cpex_core::executor::ErasedResultFields; -use cpex_core::hooks::payload::PluginPayload; +// deserializes into: a `PayloadKind` per name in the hook table, and the +// generic payload for any name outside it. The same table translates PPE's +// dispatched names to the spellings the Python worker registers. + +use praxis_policy_core::cmf::MessagePayload; +use praxis_policy_core::cmf::constants::{ + HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, + HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, + HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, +}; +use praxis_policy_core::delegation::{DelegationPayload, HOOK_TOKEN_DELEGATE}; +use praxis_policy_core::error::PluginViolation; +use praxis_policy_core::executor::ErasedResultFields; +use praxis_policy_core::hooks::payload::PluginPayload; +use praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -36,27 +44,24 @@ use crate::legacy::payloads::{ ToolPreInvokePayload, }; -/// Prefix that marks a CMF hook name. -pub const CMF_PREFIX: &str = "cmf."; - /// Wraps any JSON value for hooks with no typed payload. /// /// The third copy of this type in the workspace — `cpex-ffi` and the Python -/// bindings each define their own, because cpex-core exports the +/// bindings each define their own, because praxis-policy-core exports the /// `impl_plugin_payload!` macro but not a shared struct. Consolidating all -/// three into cpex-core is deferred follow-up work; it would touch the FFI and +/// three into praxis-policy-core is deferred follow-up work; it would touch the FFI and /// the bindings, and is not needed to ship this host. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenericPayload { pub value: Value, } -cpex_core::impl_plugin_payload!(GenericPayload); +praxis_policy_core::impl_plugin_payload!(GenericPayload); /// Which payload type a hook name maps to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PayloadKind { - /// Any `cmf.*` hook — the CMF message payload. + /// A `cmf.*` hook PPE dispatches: the CMF message payload. CmfMessage, ToolPreInvoke, ToolPostInvoke, @@ -64,39 +69,157 @@ pub enum PayloadKind { PromptPostFetch, ResourcePreFetch, ResourcePostFetch, + /// `identity.resolve`: carries the inbound credential. IdentityResolve, + /// `token.delegate`: carries the delegated token. TokenDelegate, - /// No typed payload for this name — carry the JSON as-is. + /// No typed payload for this name: carry the JSON as-is. Generic, } +/// One row of the hook table: the name the engine dispatches, the payload it +/// carries, and the name the Python worker knows it by. +struct HookRoute { + dispatched: &'static str, + kind: PayloadKind, + worker: &'static str, +} + +/// Every hook name this host gives a typed payload, keyed on the name the +/// engine dispatches. +/// +/// The CMF rows and the identity and delegation rows are PPE's dispatched +/// names. The Python framework still keys its payload registry, its hook +/// methods and its credential handling on the older spellings, so the +/// `worker` column translates on the wire: `identity.resolve` reaches the +/// worker as `identity_resolve`, `token.delegate` as `token_delegate`, and the +/// CMF prompt pair as `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch`. +/// +/// The CMF rows describe a worker that claims the `cmf_hooks` handshake +/// feature. Any other worker gets the tool, prompt and resource CMF hooks +/// through `crate::legacy::cmf_shim` instead, which picks the legacy payload +/// and hook name itself and refuses the LLM hooks. +/// +/// The six unprefixed tool, prompt and resource rows are not PPE hooks. PPE's +/// config loader rejects them unless a host registers hook metadata for them +/// and then invokes them with the matching typed payload, so they stay for +/// such hosts. The unprefixed `identity_resolve` and `token_delegate` are +/// deliberately absent: raw credentials travel only on PPE's names. +const HOOK_ROUTES: &[HookRoute] = &[ + route( + HOOK_CMF_TOOL_PRE_INVOKE, + PayloadKind::CmfMessage, + HOOK_CMF_TOOL_PRE_INVOKE, + ), + route( + HOOK_CMF_TOOL_POST_INVOKE, + PayloadKind::CmfMessage, + HOOK_CMF_TOOL_POST_INVOKE, + ), + route( + HOOK_CMF_LLM_INPUT, + PayloadKind::CmfMessage, + HOOK_CMF_LLM_INPUT, + ), + route( + HOOK_CMF_LLM_OUTPUT, + PayloadKind::CmfMessage, + HOOK_CMF_LLM_OUTPUT, + ), + route( + HOOK_CMF_PROMPT_PRE_INVOKE, + PayloadKind::CmfMessage, + "cmf.prompt_pre_fetch", + ), + route( + HOOK_CMF_PROMPT_POST_INVOKE, + PayloadKind::CmfMessage, + "cmf.prompt_post_fetch", + ), + route( + HOOK_CMF_RESOURCE_PRE_FETCH, + PayloadKind::CmfMessage, + HOOK_CMF_RESOURCE_PRE_FETCH, + ), + route( + HOOK_CMF_RESOURCE_POST_FETCH, + PayloadKind::CmfMessage, + HOOK_CMF_RESOURCE_POST_FETCH, + ), + route( + HOOK_IDENTITY_RESOLVE, + PayloadKind::IdentityResolve, + "identity_resolve", + ), + route( + HOOK_TOKEN_DELEGATE, + PayloadKind::TokenDelegate, + "token_delegate", + ), + route( + "tool_pre_invoke", + PayloadKind::ToolPreInvoke, + "tool_pre_invoke", + ), + route( + "tool_post_invoke", + PayloadKind::ToolPostInvoke, + "tool_post_invoke", + ), + route( + "prompt_pre_fetch", + PayloadKind::PromptPreFetch, + "prompt_pre_fetch", + ), + route( + "prompt_post_fetch", + PayloadKind::PromptPostFetch, + "prompt_post_fetch", + ), + route( + "resource_pre_fetch", + PayloadKind::ResourcePreFetch, + "resource_pre_fetch", + ), + route( + "resource_post_fetch", + PayloadKind::ResourcePostFetch, + "resource_post_fetch", + ), +]; + +const fn route(dispatched: &'static str, kind: PayloadKind, worker: &'static str) -> HookRoute { + HookRoute { + dispatched, + kind, + worker, + } +} + +fn lookup_route(hook_name: &str) -> Option<&'static HookRoute> { + HOOK_ROUTES.iter().find(|r| r.dispatched == hook_name) +} + /// Resolve a hook name to its payload kind. /// -/// The `cmf.` prefix is checked first: `cmf.tool_pre_invoke` must route to the -/// CMF message payload, not to the legacy tool payload whose name it contains. +/// Exact match against the hook table. A name the table does not hold, +/// including a custom `cmf.`-prefixed one, gets the generic payload. pub fn payload_kind_for_hook(hook_name: &str) -> PayloadKind { - use cpex_core::hooks::types::hook_names; - - if hook_name.starts_with(CMF_PREFIX) { - return PayloadKind::CmfMessage; - } + lookup_route(hook_name).map_or(PayloadKind::Generic, |r| r.kind) +} - match hook_name { - hook_names::TOOL_PRE_INVOKE => PayloadKind::ToolPreInvoke, - hook_names::TOOL_POST_INVOKE => PayloadKind::ToolPostInvoke, - hook_names::PROMPT_PRE_FETCH => PayloadKind::PromptPreFetch, - hook_names::PROMPT_POST_FETCH => PayloadKind::PromptPostFetch, - hook_names::RESOURCE_PRE_FETCH => PayloadKind::ResourcePreFetch, - hook_names::RESOURCE_POST_FETCH => PayloadKind::ResourcePostFetch, - hook_names::IDENTITY_RESOLVE => PayloadKind::IdentityResolve, - hook_names::TOKEN_DELEGATE => PayloadKind::TokenDelegate, - _ => PayloadKind::Generic, - } +/// The hook name the Python worker knows `hook_name` by. +/// +/// Used for the task's `hook_type` and the plugin config's `hooks` list, so the +/// worker finds the right payload model, hook method and credential slot. A +/// name outside the hook table passes through unchanged. +pub fn worker_hook_name(hook_name: &str) -> &str { + lookup_route(hook_name).map_or(hook_name, |r| r.worker) } /// Whether a hook carries raw credentials. /// -/// Only `identity_resolve` and `token_delegate` do: they are the only two +/// Only `identity.resolve` and `token.delegate` do: they are the only two /// hooks whose Python payload models a raw token at all /// (`IdentityPayload.raw_token`, `DelegationPayload.bearer_token`). The Python /// `Extensions` model has no raw-credential slot, so no other hook has @@ -138,6 +261,17 @@ pub fn serialize_payload(payload: &dyn PluginPayload) -> Result() { + return crate::credential_payloads::identity_to_worker(identity); + } + if let Some(delegation) = any.downcast_ref::() { + return crate::credential_payloads::delegation_to_worker(delegation); + } + try_downcast!( MessagePayload, ToolPreInvokePayload, @@ -158,7 +292,8 @@ pub fn serialize_payload(payload: &dyn PluginPayload) -> Result(value) .map(|p| Box::new(p) as Box) .map_err(|e| HostError::Protocol { - message: format!("worker returned a modified payload that does not match hook '{hook_name}': {e}"), + message: format!( + "worker returned a modified payload that does not match hook '{hook_name}': {e}" + ), }) } @@ -212,7 +349,7 @@ pub fn deserialize_payload( pub fn response_to_result( hook_name: &str, response: Value, - inbound: &cpex_core::extensions::Extensions, + inbound: &praxis_policy_core::extensions::Extensions, ) -> Result { let continue_processing = response .get("continue_processing") @@ -292,34 +429,62 @@ mod tests { /// tokens — so a gated slot in a response is dropped rather than applied. /// `extensions::tests` and `tests/extensions_merge_e2e.rs` cover the /// token-bearing cases. - fn no_inbound() -> cpex_core::extensions::Extensions { - cpex_core::extensions::Extensions::default() + fn no_inbound() -> praxis_policy_core::extensions::Extensions { + praxis_policy_core::extensions::Extensions::default() } // --- hook name resolution ----------------------------------------------- #[test] - fn cmf_hooks_route_to_the_message_payload() { - // The CMF-parity acceptance example. `cmf.tool_pre_invoke` contains the - // legacy name as a substring, so a match that checked the suffix or - // used `contains` would mis-route it to the tool payload. - assert_eq!( - payload_kind_for_hook("cmf.tool_pre_invoke"), - PayloadKind::CmfMessage - ); + fn every_ppe_cmf_hook_routes_to_the_message_payload() { + for hook in [ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + "cmf.llm_output", + "cmf.prompt_pre_invoke", + "cmf.prompt_post_invoke", + "cmf.resource_pre_fetch", + "cmf.resource_post_fetch", + ] { + assert_eq!( + payload_kind_for_hook(hook), + PayloadKind::CmfMessage, + "hook '{hook}' resolved wrongly" + ); + } + } + + #[test] + fn the_table_covers_every_cmf_hook_ppe_dispatches() { + // A CMF hook added upstream would otherwise fall to the generic payload + // and lose its typed modified payload without any error. + for hook in praxis_policy_core::hooks::builtin_hook_types() { + let name = hook.as_str(); + if name.starts_with("cmf.") { + assert_eq!( + payload_kind_for_hook(name), + PayloadKind::CmfMessage, + "PPE dispatches '{name}' but the hook table does not route it" + ); + } + } + } + + #[test] + fn identity_and_delegation_route_under_ppe_names() { assert_eq!( - payload_kind_for_hook("cmf.llm_input"), - PayloadKind::CmfMessage + payload_kind_for_hook("identity.resolve"), + PayloadKind::IdentityResolve ); assert_eq!( - payload_kind_for_hook("cmf.some_future_hook"), - PayloadKind::CmfMessage, - "any cmf.* name is a message payload, even one this host predates" + payload_kind_for_hook("token.delegate"), + PayloadKind::TokenDelegate ); } #[test] - fn every_legacy_hook_resolves_to_its_own_typed_payload() { + fn host_registered_legacy_hooks_keep_their_typed_payloads() { let expected = [ ("tool_pre_invoke", PayloadKind::ToolPreInvoke), ("tool_post_invoke", PayloadKind::ToolPostInvoke), @@ -327,8 +492,6 @@ mod tests { ("prompt_post_fetch", PayloadKind::PromptPostFetch), ("resource_pre_fetch", PayloadKind::ResourcePreFetch), ("resource_post_fetch", PayloadKind::ResourcePostFetch), - ("identity_resolve", PayloadKind::IdentityResolve), - ("token_delegate", PayloadKind::TokenDelegate), ]; for (hook, kind) in expected { @@ -339,37 +502,62 @@ mod tests { ); } - // All eight are distinct — a copy-paste in the match arms would show up - // here rather than as a wrong payload at runtime. + // All six are distinct: a copy-paste in the table would show up here + // rather than as a wrong payload at runtime. let kinds: std::collections::HashSet<_> = expected.iter().map(|(_, k)| format!("{k:?}")).collect(); assert_eq!( kinds.len(), - 8, + 6, "each legacy hook needs its own payload kind" ); } #[test] - fn an_unknown_hook_name_falls_back_to_the_generic_payload() { - assert_eq!( - payload_kind_for_hook("some_custom_hook"), - PayloadKind::Generic - ); - assert_eq!(payload_kind_for_hook(""), PayloadKind::Generic); + fn a_hook_outside_the_table_falls_back_to_the_generic_payload() { + for hook in [ + "some_custom_hook", + "", + // A custom name with the CMF prefix is not a CMF hook PPE dispatches. + "cmf.some_future_hook", + // PPE's own HTTP and elicitation hooks have no Python payload model. + "http.request", + "elicit", + // The unprefixed identity and delegation names are gone: credentials + // travel only on PPE's names. + "identity_resolve", + "token_delegate", + // The old CMF prompt spelling is the worker's, not a dispatched name. + "cmf.prompt_pre_fetch", + ] { + assert_eq!( + payload_kind_for_hook(hook), + PayloadKind::Generic, + "'{hook}' must not get a typed payload" + ); + assert_eq!( + worker_hook_name(hook), + hook, + "'{hook}' must reach the worker unchanged" + ); + } } #[test] fn only_identity_and_delegation_are_credential_hooks() { - assert!(is_credential_hook("identity_resolve")); - assert!(is_credential_hook("token_delegate")); + assert!(is_credential_hook("identity.resolve")); + assert!(is_credential_hook("token.delegate")); for hook in [ + "identity_resolve", + "token_delegate", "tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "resource_pre_fetch", "cmf.tool_pre_invoke", + "cmf.prompt_pre_invoke", + "http.request", "unknown", ] { assert!( @@ -379,6 +567,55 @@ mod tests { } } + #[test] + fn ppe_names_translate_to_the_worker_spelling() { + let expected = [ + ("identity.resolve", "identity_resolve"), + ("token.delegate", "token_delegate"), + ("cmf.prompt_pre_invoke", "cmf.prompt_pre_fetch"), + ("cmf.prompt_post_invoke", "cmf.prompt_post_fetch"), + ("cmf.tool_pre_invoke", "cmf.tool_pre_invoke"), + ("cmf.tool_post_invoke", "cmf.tool_post_invoke"), + ("cmf.llm_input", "cmf.llm_input"), + ("cmf.llm_output", "cmf.llm_output"), + ("cmf.resource_pre_fetch", "cmf.resource_pre_fetch"), + ("cmf.resource_post_fetch", "cmf.resource_post_fetch"), + ("tool_pre_invoke", "tool_pre_invoke"), + ]; + for (dispatched, worker) in expected { + assert_eq!(worker_hook_name(dispatched), worker, "{dispatched}"); + } + } + + #[test] + fn identity_and_delegation_modified_payloads_keep_their_types() { + let boxed = deserialize_payload( + "identity.resolve", + serde_json::json!({ "raw_token": "**********", "source": "bearer" }), + ) + .expect("parses"); + assert!( + boxed + .as_any() + .downcast_ref::() + .is_some(), + "identity.resolve must come back as the identity payload" + ); + + let boxed = deserialize_payload( + "token.delegate", + serde_json::json!({ "target_name": "billing-api" }), + ) + .expect("parses"); + assert!( + boxed + .as_any() + .downcast_ref::() + .is_some(), + "token.delegate must come back as the delegation payload" + ); + } + // --- outbound serialization --------------------------------------------- #[test] @@ -411,7 +648,7 @@ mod tests { #[test] fn a_cmf_message_payload_serializes_as_itself() { - use cpex_core::cmf::{Message, Role}; + use praxis_policy_core::cmf::{Message, Role}; let payload = MessagePayload { message: Message::text(Role::User, "what is the weather?"), @@ -424,7 +661,7 @@ mod tests { fn an_unforwardable_payload_type_errors_rather_than_sending_null() { #[derive(Debug, Clone)] struct ForeignPayload; - cpex_core::impl_plugin_payload!(ForeignPayload); + praxis_policy_core::impl_plugin_payload!(ForeignPayload); let err = serialize_payload(&ForeignPayload).expect_err("an unknown type cannot be forwarded"); @@ -447,7 +684,7 @@ mod tests { #[test] fn a_cmf_modified_payload_deserializes_as_a_message_payload() { - use cpex_core::cmf::{Message, Role}; + use praxis_policy_core::cmf::{Message, Role}; // Round-tripped through the real type so the JSON matches the CMF // schema rather than a hand-guessed shape. diff --git a/crates/cpex-hosts-python/src/credential_payloads.rs b/crates/cpex-hosts-python/src/credential_payloads.rs new file mode 100644 index 00000000..75826440 --- /dev/null +++ b/crates/cpex-hosts-python/src/credential_payloads.rs @@ -0,0 +1,1062 @@ +// Location: ./crates/cpex-hosts-python/src/credential_payloads.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Ted Habeck +// +// PPE's identity and delegation payloads, on the worker's wire. +// +// # Why this exists +// +// PPE dispatches `identity.resolve` with `praxis_policy_core::identity:: +// IdentityPayload` and `token.delegate` with `delegation::DelegationPayload`. +// The FFI, the Python bindings and the APL delegate step all build those +// types. The Python worker knows neither: it rebuilds its own +// `IdentityPayload` / `DelegationPayload` Pydantic models (the shapes the +// `legacy::payloads` structs mirror), and its plugins answer with an +// `IdentityResult` / `DelegationResult`. This module translates both ways: +// +// outbound: the PPE payload ──> the worker model's JSON, minus every secret +// inbound: the plugin's result ──> a clone of the dispatched PPE payload +// with only the output fields the result carries written +// +// Mapping back onto a clone (the `cmf_shim` pattern) is what lets PPE's own +// downcasts succeed: `IdentityPayload::from_pipeline_result` and +// `DelegationPayload::from_pipeline_result` look for exactly these types, and +// the input fields PPE keeps private stay what the host set. +// +// # Credentials +// +// No raw token travels in the JSON built here. `raw_token` is sent empty and +// `bearer_token` is not sent at all; a plugin that declared the matching +// capability gets the plaintext from the separate `credential` object +// (`crate::credentials`), and only that plugin does. The identity headers are +// the other channel a token can ride, so the public copy drops the +// credential-bearing ones: the configured source header, `authorization`, +// `proxy-authorization`, cookies, `x-api-key`, and any header whose name or +// value contains the token. The capability-gated `credential` object carries +// the full header map instead. +// +// # Field decisions +// +// identity.resolve, outbound +// raw_token sent empty; plaintext only on the gated channel +// source sent (PPE's wire spelling) +// source_header not sent; the gated channel names it +// headers sent minus credential-bearing headers +// client_host/port sent +// resolver outputs not sent; the Python model has no slot for them, +// and they survive on the clone +// +// identity.resolve, inbound (`IdentityResult`, or an echoed payload) +// subject, delegation written when set; a field PPE cannot hold fails +// closed +// raw_claims merged +// rejected true becomes a deny carrying reject_reason and +// reject_status +// input fields ignored: PPE treats them as canonical for the +// chain, as `IdentityPayload::merge` does +// anything else fails closed when set +// +// token.delegate, outbound +// bearer_token not sent; plaintext only on the gated channel +// actor token / role refused when set: the Python model cannot carry +// an actor, so a minted token would lose its `act` +// subject refused unless `user`: the Python model assumes +// the bearer is the user's token +// everything else sent +// +// token.delegate, inbound (`DelegationResult`, or an echoed payload) +// delegated_token becomes `delegated_token`, for the target +// audience (or target name) and the required +// permissions +// forwarded_headers accepted only as the one header that carries the +// minted token, which names its outbound header; +// anything else fails closed +// cache_ttl bounds the minted token's expiry +// cache_key ignored: PPE leaves caching to the handler +// delegation_update written when it is not the empty default +// input fields ignored, as above +// anything else fails closed when set + +use std::collections::HashMap; + +use praxis_policy_core::delegation::{DelegationPayload, DelegationSubject, HOOK_TOKEN_DELEGATE}; +use praxis_policy_core::error::PluginViolation; +use praxis_policy_core::extensions::{DelegationExtension, RawDelegatedToken, SubjectExtension}; +use praxis_policy_core::hooks::payload::PluginPayload; +use praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::error::HostError; + +/// Header an identity payload's credential arrives in when it names none. +const DEFAULT_CREDENTIAL_HEADER: &str = "authorization"; + +/// Header names that carry credentials by convention, compared lowercase. +/// Never sent in the public header map, whatever the source header is. +pub(crate) const CREDENTIAL_HEADERS: &[&str] = &[ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "x-auth-token", + "x-access-token", + "x-amzn-oidc-data", + "x-goog-iap-jwt-assertion", + "x-amz-security-token", +]; + +/// A public header needs both an approved name and a validator for its value. +/// Empty by default: unvalidated header values can contain credentials under +/// any name, including names that normally carry tracing metadata. +const PUBLIC_HEADER_VALIDATORS: &[(&str, fn(&str) -> bool)] = &[]; + +/// Header the minted token is attached under when the plugin names none. +const DEFAULT_OUTBOUND_HEADER: &str = "Authorization"; + +/// Lifetime given to a minted token whose plugin reported no `cache_ttl`. +/// +/// The Python `DelegationResult` has no expiry field, so the host has to pick +/// one. Short on purpose: an expiry the issuer did not state should make a +/// consumer re-delegate soon rather than reuse the token for long. +const DEFAULT_DELEGATED_TOKEN_TTL_SECS: i64 = 60; + +/// Fields of the worker's `IdentityPayload` model. A plugin that echoes its +/// payload back returns these; PPE keeps the matching fields private. +const IDENTITY_INPUT_FIELDS: &[&str] = &[ + "raw_token", + "source", + "headers", + "client_host", + "client_port", +]; + +/// Fields of the worker's `DelegationPayload` model, ignored on the way back +/// for the same reason. +const DELEGATION_INPUT_FIELDS: &[&str] = &[ + "target_name", + "target_type", + "target_audience", + "required_permissions", + "trust_domain", + "auth_enforced_by", + "route_attenuation", + "bearer_token", +]; + +/// Whether `payload` is one of the two PPE payloads this module translates. +pub fn is_ppe_credential_payload(payload: &dyn PluginPayload) -> bool { + let any = payload.as_any(); + any.is::() || any.is::() +} + +// --- identity.resolve ------------------------------------------------------- + +/// The inbound credential an identity payload carries, if any. +/// +/// `raw_token` first. A host that deserialized the payload (the FFI and the +/// Python bindings do) has an empty `raw_token` and the credential in the +/// header map, so the source header is read next, with a `Bearer ` prefix +/// stripped the way PPE's own JWT resolver strips it. +pub(crate) fn inbound_token(payload: &IdentityPayload) -> Option { + let raw = payload.raw_token(); + if !raw.trim().is_empty() { + return Some(raw.to_string()); + } + let header = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER); + let (_, value) = header_entry(payload.headers(), header)?; + let token = strip_bearer(value).trim(); + (!token.is_empty()).then(|| token.to_string()) +} + +/// The header the credential arrived in, spelled as the header map spells it +/// when the map holds it. +pub(crate) fn credential_header_name(payload: &IdentityPayload) -> String { + let header = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER); + header_entry(payload.headers(), header) + .map_or_else(|| header.to_string(), |(name, _)| name.to_string()) +} + +/// Case-insensitive header lookup, returning the key as stored. +fn header_entry<'a>( + headers: &'a HashMap, + name: &str, +) -> Option<(&'a str, &'a str)> { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(k, v)| (k.as_str(), v.as_str())) +} + +fn strip_bearer(value: &str) -> &str { + let trimmed = value.trim_start(); + match trimmed.get(..7) { + Some(prefix) if prefix.eq_ignore_ascii_case("bearer ") => &trimmed[7..], + _ => trimmed, + } +} + +/// Whether a raw header may cross to a worker outside the gated credential +/// channel. No name is approved yet: a familiar header name alone cannot +/// prove its value contains no credential. Add an entry to +/// `PUBLIC_HEADER_VALIDATORS` only with a strict validator and a documented +/// need for that field. +pub(crate) fn is_public_header( + name: &str, + value: &str, + source_header: Option<&str>, + token: Option<&str>, +) -> bool { + let lower = name.to_ascii_lowercase(); + if CREDENTIAL_HEADERS.contains(&lower.as_str()) + || source_header.is_some_and(|source| lower.eq_ignore_ascii_case(source)) + || token.is_some_and(|t| name.contains(t) || value.contains(t)) + { + return false; + } + PUBLIC_HEADER_VALIDATORS + .iter() + .any(|(approved, validate)| lower == *approved && validate(value)) +} + +/// Public request headers for an identity plugin. The full map is available +/// only through the `read_inbound_credentials`-gated credential DTO. +pub(crate) fn public_headers(payload: &IdentityPayload) -> HashMap { + let token = inbound_token(payload); + let source = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER); + payload + .headers() + .iter() + .filter(|(name, value)| is_public_header(name, value, Some(source), token.as_deref())) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() +} + +/// The worker `IdentityPayload` JSON for a PPE identity payload. +pub fn identity_to_worker(payload: &IdentityPayload) -> Result { + let source = serde_json::to_value(payload.source()).map_err(|e| HostError::Protocol { + message: format!("could not serialize the identity payload's source: {e}"), + })?; + let mut json = serde_json::json!({ + // Required by the Python model. Empty here; the plaintext reaches a + // plugin that declared `read_inbound_credentials` through the + // `credential` object, which the worker folds onto this field. + "raw_token": "", + "source": source, + "headers": public_headers(payload), + }); + if let Some(host) = payload.client_host() { + json["client_host"] = Value::String(host.to_string()); + } + if let Some(port) = payload.client_port() { + json["client_port"] = Value::from(port); + } + Ok(json) +} + +/// What an identity plugin's result means for the PPE pipeline. +#[derive(Debug)] +pub enum IdentityOutcome { + /// The dispatched payload with the plugin's output fields written. + Resolved(Box), + /// The plugin set `rejected`. Carried as a deny, which PPE's identity + /// model uses in place of a flag on the payload. + Rejected(PluginViolation), +} + +/// Map an identity plugin's modified payload back onto a clone of the one +/// PPE dispatched. +pub fn identity_from_worker( + original: &IdentityPayload, + modified: Value, +) -> Result { + let fields = object(HOOK_IDENTITY_RESOLVE, modified)?; + for (key, value) in &fields { + let known = matches!( + key.as_str(), + "subject" + | "delegation" + | "raw_claims" + | "rejected" + | "reject_status" + | "reject_reason" + ) || IDENTITY_INPUT_FIELDS.contains(&key.as_str()); + if !known && !is_empty(value) { + return Err(unrepresentable(HOOK_IDENTITY_RESOLVE, key)); + } + } + + match fields.get("rejected") { + None | Some(Value::Null) | Some(Value::Bool(false)) => {}, + Some(Value::Bool(true)) => return Ok(IdentityOutcome::Rejected(rejection(&fields)?)), + Some(_) => { + return Err(protocol( + HOOK_IDENTITY_RESOLVE, + "`rejected` must be a boolean", + )); + }, + } + + let mut resolved = original.clone(); + if let Some(subject) = fields.get("subject").filter(|v| !v.is_null()) { + resolved.subject = Some(parse_lossless::( + HOOK_IDENTITY_RESOLVE, + "subject", + &python_subject(subject), + )?); + } + if let Some(delegation) = fields.get("delegation").filter(|v| !v.is_null()) { + resolved.delegation = Some(parse_lossless::( + HOOK_IDENTITY_RESOLVE, + "delegation", + delegation, + )?); + } + match fields.get("raw_claims") { + None | Some(Value::Null) => {}, + Some(Value::Object(claims)) => { + for (name, value) in claims { + resolved.raw_claims.insert(name.clone(), value.clone()); + } + }, + Some(_) => { + return Err(protocol( + HOOK_IDENTITY_RESOLVE, + "`raw_claims` must be an object", + )); + }, + } + Ok(IdentityOutcome::Resolved(Box::new(resolved))) +} + +/// The deny a `rejected` identity result stands for. +fn rejection(fields: &Map) -> Result { + let reason = match fields.get("reject_reason") { + None | Some(Value::Null) => "", + Some(Value::String(reason)) => reason.as_str(), + Some(_) => { + return Err(protocol( + HOOK_IDENTITY_RESOLVE, + "`reject_reason` must be a string", + )); + }, + }; + let reason = if reason.is_empty() { + "the identity plugin rejected the credential" + } else { + reason + }; + let mut violation = PluginViolation::new("auth.rejected", reason); + match fields.get("reject_status") { + None | Some(Value::Null) => {}, + Some(status) => { + let status = status.as_i64().ok_or_else(|| { + protocol(HOOK_IDENTITY_RESOLVE, "`reject_status` must be an integer") + })?; + violation = violation.with_proto_error_code(status); + violation + .details + .insert("reject_status".into(), Value::from(status)); + }, + } + Ok(violation) +} + +/// The Python `SubjectExtension` names its kind `type`; PPE's names it +/// `subject_type`. Renamed so the lossless check does not read it as dropped. +fn python_subject(subject: &Value) -> Value { + let mut subject = subject.clone(); + if let Some(fields) = subject.as_object_mut() + && !fields.contains_key("subject_type") + && let Some(kind) = fields.remove("type") + { + fields.insert("subject_type".into(), kind); + } + subject +} + +// --- token.delegate --------------------------------------------------------- + +/// The worker `DelegationPayload` JSON for a PPE delegation payload. +/// +/// Refuses an exchange the Python model cannot describe, before anything +/// reaches the worker. +pub fn delegation_to_worker(payload: &DelegationPayload) -> Result { + if payload.actor_role().is_some() || !payload.actor_token().is_empty() { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "the delegation carries an RFC 8693 actor, which the Python DelegationPayload cannot \ + carry; a token minted without it would lose its `act` attribution", + )); + } + if payload.subject() != &DelegationSubject::User { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "the delegation is for a subject other than `user`, and the Python DelegationPayload \ + reads its bearer token as the user's; a plugin would attribute the minted token to \ + the wrong principal", + )); + } + + let mut json = serde_json::json!({ + "target_name": payload.target_name(), + "target_type": to_json(payload.target_type())?, + "required_permissions": payload.required_permissions(), + "auth_enforced_by": to_json(&payload.auth_enforced_by())?, + }); + if let Some(audience) = payload.target_audience() { + json["target_audience"] = Value::String(audience.to_string()); + } + if let Some(domain) = payload.trust_domain() { + json["trust_domain"] = Value::String(domain.to_string()); + } + if let Some(attenuation) = payload.route_attenuation() { + json["route_attenuation"] = to_json(attenuation)?; + } + Ok(json) +} + +/// Map a delegation plugin's modified payload back onto a clone of the one +/// PPE dispatched. +pub fn delegation_from_worker( + original: &DelegationPayload, + modified: Value, +) -> Result { + let fields = object(HOOK_TOKEN_DELEGATE, modified)?; + for (key, value) in &fields { + let known = matches!( + key.as_str(), + "delegated_token" + | "delegation_update" + | "forwarded_headers" + | "cache_key" + | "cache_ttl" + ) || DELEGATION_INPUT_FIELDS.contains(&key.as_str()); + if !known && !is_empty(value) { + return Err(unrepresentable(HOOK_TOKEN_DELEGATE, key)); + } + } + + let token = match fields.get("delegated_token") { + None | Some(Value::Null) => None, + Some(Value::String(token)) if !token.trim().is_empty() => Some(token.as_str()), + Some(Value::String(_)) => { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "the plugin returned an empty `delegated_token`", + )); + }, + Some(_) => { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "`delegated_token` must be a string", + )); + }, + }; + let forwarded = match fields.get("forwarded_headers") { + None | Some(Value::Null) => Map::new(), + Some(Value::Object(headers)) => headers.clone(), + Some(_) => { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "`forwarded_headers` must be an object", + )); + }, + }; + + let mut resolved = original.clone(); + match token { + Some(token) => { + let outbound_header = outbound_header(token, &forwarded)?; + let now = chrono::Utc::now(); + let ttl = match fields.get("cache_ttl") { + None | Some(Value::Null) => DEFAULT_DELEGATED_TOKEN_TTL_SECS, + Some(ttl) => ttl.as_i64().filter(|secs| *secs > 0).ok_or_else(|| { + protocol( + HOOK_TOKEN_DELEGATE, + "`cache_ttl` must be a positive integer", + ) + })?, + }; + let audience = original + .target_audience() + .unwrap_or_else(|| original.target_name()) + .to_string(); + let expires_at = chrono::Duration::try_seconds(ttl) + .and_then(|duration| now.checked_add_signed(duration)) + .ok_or_else(|| protocol(HOOK_TOKEN_DELEGATE, "`cache_ttl` is out of range"))?; + resolved.delegated_token = Some(RawDelegatedToken::new( + token, + outbound_header, + audience, + original.required_permissions().to_vec(), + expires_at, + )); + resolved.delegation_mode = Some(original.subject().default_mode()); + resolved.minted_at = Some(now); + }, + None if !forwarded.is_empty() => { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "the plugin returned `forwarded_headers` with no `delegated_token`, and a PPE \ + delegation payload carries headers only as the minted token's outbound header", + )); + }, + None => {}, + } + + if let Some(update) = fields.get("delegation_update").filter(|v| !v.is_null()) { + let update: DelegationExtension = + parse_lossless(HOOK_TOKEN_DELEGATE, "delegation_update", update)?; + if !is_default_delegation(&update) { + resolved.delegation_update = Some(update); + } + } + Ok(resolved) +} + +/// The header the minted token goes upstream in. +/// +/// The Python result can name it through `forwarded_headers`. The PPE payload +/// holds one outbound header for one token, so the only shape that maps is a +/// single header whose value is the token, bare or as a bearer. +fn outbound_header(token: &str, forwarded: &Map) -> Result { + let mut entries = forwarded.iter(); + let Some((name, value)) = entries.next() else { + return Ok(DEFAULT_OUTBOUND_HEADER.to_string()); + }; + let carries_token = value + .as_str() + .is_some_and(|v| v == token || strip_bearer(v) == token); + if entries.next().is_some() || !carries_token { + return Err(protocol( + HOOK_TOKEN_DELEGATE, + "the plugin returned `forwarded_headers` other than the single header carrying the \ + minted token, and a PPE delegation payload cannot carry extra headers", + )); + } + Ok(name.clone()) +} + +fn is_default_delegation(update: &DelegationExtension) -> bool { + update.chain.is_empty() + && update.depth == 0 + && update.origin_subject_id.is_none() + && update.actor_subject_id.is_none() + && !update.delegated +} + +// --- shared ----------------------------------------------------------------- + +fn object(hook: &str, value: Value) -> Result, HostError> { + match value { + Value::Object(fields) => Ok(fields), + _ => Err(protocol(hook, "the modified payload is not a JSON object")), + } +} + +/// Deserialize into a PPE type, failing closed when that drops a set field. +/// +/// PPE's extension types ignore fields they do not know, so a plain +/// `from_value` would silently lose whatever the Python model has and PPE's +/// does not. Serializing back and comparing which set fields survived catches +/// that. The error names the field path, never a value. +fn parse_lossless( + hook: &str, + field: &str, + value: &Value, +) -> Result { + let parsed: T = serde_json::from_value(value.clone()).map_err(|_| { + protocol( + hook, + &format!("`{field}` does not match the PPE type it maps to"), + ) + })?; + let back = serde_json::to_value(&parsed).map_err(|e| HostError::Protocol { + message: format!("could not re-serialize `{field}`: {e}"), + })?; + if let Some(path) = first_dropped(value, &back, field) { + return Err(unrepresentable(hook, &path)); + } + Ok(parsed) +} + +/// The path of the first set field in `original` that `round_tripped` lost. +fn first_dropped(original: &Value, round_tripped: &Value, path: &str) -> Option { + match (original, round_tripped) { + (Value::Object(fields), Value::Object(kept)) => fields.iter().find_map(|(key, value)| { + if is_empty(value) { + return None; + } + let path = format!("{path}.{key}"); + match kept.get(key) { + None => Some(path), + Some(kept) => first_dropped(value, kept, &path), + } + }), + (Value::Array(items), Value::Array(kept)) => { + if items.len() != kept.len() { + return Some(path.to_string()); + } + items + .iter() + .zip(kept) + .enumerate() + .find_map(|(i, (item, kept))| first_dropped(item, kept, &format!("{path}[{i}]"))) + }, + _ => None, + } +} + +/// A value that carries nothing: what Pydantic dumps for an unset field. +fn is_empty(value: &Value) -> bool { + match value { + Value::Null | Value::Bool(false) => true, + Value::String(s) => s.is_empty(), + Value::Array(items) => items.is_empty(), + Value::Object(fields) => fields.is_empty(), + Value::Bool(true) | Value::Number(_) => false, + } +} + +fn to_json(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| HostError::Protocol { + message: format!("could not serialize a delegation payload field: {e}"), + }) +} + +fn protocol(hook: &str, message: &str) -> HostError { + HostError::Protocol { + message: format!("hook '{hook}': {message}"), + } +} + +fn unrepresentable(hook: &str, field: &str) -> HostError { + protocol( + hook, + &format!( + "the plugin returned `{field}`, which the PPE payload has nowhere to hold; refusing \ + to drop it silently" + ), + ) +} + +#[cfg(test)] +mod tests { + use praxis_policy_core::delegation::AttenuationConfig; + use praxis_policy_core::extensions::raw_credentials::TokenRole; + use praxis_policy_core::extensions::{DelegationHop, SubjectType}; + use praxis_policy_core::identity::TokenSource; + use serde_json::json; + + use super::*; + + const TOKEN: &str = "eyJhbGciOiJSUzI1NiJ9.IDENTITY-PLAINTEXT.sig"; + const MINTED: &str = "MINTED-DELEGATED-PLAINTEXT"; + + fn identity() -> IdentityPayload { + IdentityPayload::new(TOKEN, TokenSource::Bearer) + .with_source_header("x-user-token") + .with_headers(HashMap::from([ + ("authorization".into(), format!("Bearer {TOKEN}")), + ("x-user-token".into(), "opaque-user-credential".into()), + ("cookie".into(), "session=COOKIE-SECRET".into()), + ("x-echo".into(), format!("copy:{TOKEN}")), + ("x-tenant".into(), "acme".into()), + ])) + .with_client_host("10.0.0.1") + .with_client_port(8443) + } + + fn delegation() -> DelegationPayload { + DelegationPayload::new(TOKEN, "billing-api") + .with_target_audience("https://billing.example.com") + .with_required_permissions(vec!["invoices:read".into()]) + .with_trust_domain("example.org") + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["read".into()], + ttl_seconds: Some(120), + ..Default::default() + }) + } + + fn resolved(outcome: IdentityOutcome) -> IdentityPayload { + match outcome { + IdentityOutcome::Resolved(payload) => *payload, + IdentityOutcome::Rejected(v) => panic!("expected a resolved payload, got {v:?}"), + } + } + + // --- identity outbound ---------------------------------------------------- + + #[test] + fn identity_json_carries_no_credential_material() { + let json = identity_to_worker(&identity()).unwrap(); + let wire = json.to_string(); + assert!(!wire.contains("IDENTITY-PLAINTEXT"), "token leaked: {wire}"); + assert!( + !wire.contains("opaque-user-credential"), + "source header leaked: {wire}" + ); + assert!(!wire.contains("COOKIE-SECRET"), "cookie leaked: {wire}"); + + assert_eq!(json["raw_token"], ""); + assert_eq!(json["source"], "bearer"); + assert_eq!(json["headers"], json!({})); + assert_eq!(json["client_host"], "10.0.0.1"); + assert_eq!(json["client_port"], 8443); + } + + #[test] + fn identity_json_matches_the_worker_model() { + // The legacy struct mirrors the worker's Pydantic model field for field. + let json = identity_to_worker(&identity()).unwrap(); + let mirrored: crate::legacy::IdentityResolvePayload = + serde_json::from_value(json).expect("the worker model accepts the shape"); + assert_eq!(mirrored.source, "bearer"); + } + + #[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(); + assert_eq!(inbound_token(&payload).as_deref(), Some(TOKEN)); + assert_eq!(credential_header_name(&payload), "Authorization"); + assert!(public_headers(&payload).is_empty()); + } + + #[test] + fn a_custom_source_keeps_its_wire_spelling() { + let payload = IdentityPayload::new(TOKEN, TokenSource::Custom("magic-link".into())); + assert_eq!( + identity_to_worker(&payload).unwrap()["source"], + "magic-link" + ); + } + + // --- identity inbound ----------------------------------------------------- + + #[test] + fn an_identity_result_maps_onto_a_clone_of_the_dispatched_payload() { + let original = identity(); + let back = resolved( + identity_from_worker( + &original, + json!({ + "subject": { + "id": "alice", "type": "user", "roles": ["hr"], + "permissions": [], "teams": [], "claims": {"dept": "people"}, + }, + "delegation": null, + "rejected": false, + "reject_status": 401, + "reject_reason": "", + "raw_claims": {"sub": "alice"}, + }), + ) + .unwrap(), + ); + + let subject = back.subject.as_ref().expect("the subject is written"); + assert_eq!(subject.id.as_deref(), Some("alice")); + assert_eq!(subject.subject_type, Some(SubjectType::User)); + assert!(subject.roles.contains("hr")); + assert_eq!(subject.claims["dept"], "people"); + assert_eq!(back.raw_claims["sub"], "alice"); + + // Input fields are the dispatched ones, so PPE's later steps still + // read the host's credential. + assert_eq!(back.raw_token(), TOKEN); + assert_eq!(back.source_header(), Some("x-user-token")); + assert_eq!(back.headers().len(), original.headers().len()); + } + + #[test] + fn a_resolved_identity_survives_ppes_pipeline_downcast() { + let back = resolved( + identity_from_worker( + &identity(), + json!({"subject": {"id": "alice", "type": "user"}}), + ) + .unwrap(), + ); + let boxed: Box = Box::new(back); + let result = praxis_policy_core::executor::PipelineResult::allowed_with( + boxed, + praxis_policy_core::extensions::Extensions::default(), + Default::default(), + ); + let extracted = IdentityPayload::from_pipeline_result(&result) + .expect("PPE downcasts the modified payload to IdentityPayload"); + assert_eq!( + extracted.subject.and_then(|s| s.id).as_deref(), + Some("alice") + ); + } + + #[test] + fn an_echoed_identity_payload_changes_nothing() { + let original = identity(); + let echo = json!({ + "raw_token": "**********", "source": "custom", + "headers": {"x-injected": "1"}, "client_host": "6.6.6.6", "client_port": 1, + }); + let back = resolved(identity_from_worker(&original, echo).unwrap()); + assert!(back.subject.is_none()); + assert_eq!( + back.client_host(), + Some("10.0.0.1"), + "inputs stay canonical" + ); + assert_eq!(back.raw_token(), TOKEN); + } + + #[test] + fn a_rejected_identity_becomes_a_deny() { + let outcome = identity_from_worker( + &identity(), + json!({ + "subject": {"id": "alice", "type": "user"}, + "rejected": true, "reject_status": 403, "reject_reason": "token expired", + }), + ) + .unwrap(); + let IdentityOutcome::Rejected(violation) = outcome else { + panic!("rejected must deny even when a subject is set"); + }; + assert_eq!(violation.code, "auth.rejected"); + assert_eq!(violation.reason, "token expired"); + assert_eq!(violation.proto_error_code, Some(403)); + assert_eq!(violation.details["reject_status"], 403); + } + + #[test] + fn an_unknown_identity_result_field_fails_closed() { + let err = identity_from_worker(&identity(), json!({"client": {"id": "gw"}})).unwrap_err(); + assert!(matches!(err, HostError::Protocol { .. }), "{err:?}"); + assert!(err.to_string().contains("`client`"), "{err}"); + } + + #[test] + fn a_subject_field_ppe_cannot_hold_fails_closed() { + let err = identity_from_worker( + &identity(), + json!({"subject": {"id": "alice", "type": "user", "tenant": "acme"}}), + ) + .unwrap_err(); + assert!(err.to_string().contains("subject.tenant"), "{err}"); + } + + #[test] + fn a_malformed_subject_fails_closed_without_echoing_values() { + let err = identity_from_worker( + &identity(), + json!({"subject": {"id": "SUBJECT-VALUE", "type": "SUBJECT-VALUE"}}), + ) + .unwrap_err(); + assert!(!err.to_string().contains("SUBJECT-VALUE"), "{err}"); + } + + // --- delegation outbound -------------------------------------------------- + + #[test] + fn delegation_json_matches_the_worker_model_and_carries_no_token() { + let json = delegation_to_worker(&delegation()).unwrap(); + assert!(!json.to_string().contains("IDENTITY-PLAINTEXT"), "{json}"); + assert!(json.get("bearer_token").is_none()); + + let mirrored: crate::legacy::TokenDelegatePayload = + serde_json::from_value(json.clone()).expect("the worker model accepts the shape"); + assert_eq!(mirrored.target_name, "billing-api"); + assert_eq!(mirrored.target_type, "tool"); + assert_eq!( + mirrored.target_audience.as_deref(), + Some("https://billing.example.com") + ); + assert_eq!(mirrored.required_permissions, vec!["invoices:read"]); + assert_eq!(mirrored.trust_domain.as_deref(), Some("example.org")); + assert_eq!(mirrored.auth_enforced_by, "caller"); + assert_eq!(json["route_attenuation"]["ttl_seconds"], 120); + } + + #[test] + fn a_delegation_with_an_actor_fails_closed() { + let payload = delegation().with_actor(TokenRole::CallerWorkload, "svid"); + let err = delegation_to_worker(&payload).unwrap_err(); + assert!(err.to_string().contains("actor"), "{err}"); + } + + #[test] + fn a_delegation_for_another_subject_fails_closed() { + let payload = delegation().with_subject(DelegationSubject::ThisWorkload); + let err = delegation_to_worker(&payload).unwrap_err(); + assert!(err.to_string().contains("subject"), "{err}"); + } + + // --- delegation inbound --------------------------------------------------- + + #[test] + fn a_delegation_result_maps_onto_a_clone_of_the_dispatched_payload() { + let original = delegation(); + let back = delegation_from_worker( + &original, + json!({ + "delegated_token": MINTED, + "delegation_update": { + "chain": [{"subject_id": "alice", "subject_type": "user", + "audience": "https://billing.example.com", + "scopes_granted": ["invoices:read"], + "timestamp": "2026-01-01T00:00:00Z", + "ttl_seconds": null, "strategy": "token_exchange", + "from_cache": false}], + "depth": 1, "origin_subject_id": "alice", "actor_subject_id": "alice", + "delegated": true, "age_seconds": 0.0, + }, + "forwarded_headers": {"X-Upstream-Auth": format!("Bearer {MINTED}")}, + "cache_key": "k", + "cache_ttl": 300, + }), + ) + .unwrap(); + + let token = back.delegated_token.as_ref().expect("the token is written"); + assert_eq!(token.token.as_str(), MINTED); + assert_eq!(token.outbound_header, "X-Upstream-Auth"); + assert_eq!(token.audience, "https://billing.example.com"); + assert_eq!(token.scopes, vec!["invoices:read"]); + let ttl = token.expires_at - chrono::Utc::now(); + assert!(ttl.num_seconds() > 250 && ttl.num_seconds() <= 300, "{ttl}"); + assert_eq!( + back.delegation_mode, + Some(praxis_policy_core::extensions::raw_credentials::DelegationMode::OnBehalfOfUser) + ); + let update = back + .delegation_update + .as_ref() + .expect("the chain update is written"); + assert_eq!(update.depth, 1); + assert_eq!(update.chain.len(), 1); + + // The caller's credential stays on the clone for PPE to apply. + assert_eq!(back.bearer_token(), TOKEN); + + let boxed: Box = Box::new(back); + let result = praxis_policy_core::executor::PipelineResult::allowed_with( + boxed, + praxis_policy_core::extensions::Extensions::default(), + Default::default(), + ); + assert!( + DelegationPayload::from_pipeline_result(&result).is_some(), + "PPE downcasts the modified payload to DelegationPayload" + ); + } + + #[test] + fn a_minimal_delegation_result_uses_the_defaults() { + let back = delegation_from_worker( + &delegation(), + json!({"delegated_token": MINTED, "delegation_update": { + "chain": [], "depth": 0, "origin_subject_id": null, + "actor_subject_id": null, "delegated": false, "age_seconds": 0.0, + }, "forwarded_headers": {}, "cache_key": null, "cache_ttl": null}), + ) + .unwrap(); + let token = back.delegated_token.unwrap(); + assert_eq!(token.outbound_header, DEFAULT_OUTBOUND_HEADER); + assert!( + back.delegation_update.is_none(), + "an empty default update is no update" + ); + } + + #[test] + fn out_of_range_delegation_ttl_is_a_protocol_error() { + let err = delegation_from_worker( + &delegation(), + json!({"delegated_token": MINTED, "cache_ttl": i64::MAX}), + ) + .unwrap_err(); + assert!(matches!(err, HostError::Protocol { .. }), "{err:?}"); + assert!(err.to_string().contains("`cache_ttl` is out of range")); + } + + #[test] + fn extra_forwarded_headers_fail_closed() { + for forwarded in [ + json!({"Authorization": format!("Bearer {MINTED}"), "X-Extra": "1"}), + json!({"X-Other": "not-the-token"}), + ] { + let err = delegation_from_worker( + &delegation(), + json!({"delegated_token": MINTED, "forwarded_headers": forwarded}), + ) + .unwrap_err(); + assert!(err.to_string().contains("forwarded_headers"), "{err}"); + assert!(!err.to_string().contains(MINTED), "{err}"); + } + } + + #[test] + fn forwarded_headers_without_a_token_fail_closed() { + let err = delegation_from_worker( + &delegation(), + json!({"forwarded_headers": {"Authorization": "Bearer x"}}), + ) + .unwrap_err(); + assert!(err.to_string().contains("forwarded_headers"), "{err}"); + } + + #[test] + fn an_unknown_delegation_result_field_fails_closed() { + let err = delegation_from_worker( + &delegation(), + json!({"delegated_token": MINTED, "refresh_token": "r"}), + ) + .unwrap_err(); + assert!(err.to_string().contains("refresh_token"), "{err}"); + assert!(!err.to_string().contains(MINTED), "{err}"); + } + + #[test] + fn a_delegation_hop_field_ppe_cannot_hold_fails_closed() { + let hop = serde_json::to_value(DelegationHop { + subject_id: "alice".into(), + ..Default::default() + }) + .unwrap(); + let mut hop = hop.as_object().unwrap().clone(); + hop.insert("issuer".into(), json!("idp")); + let err = delegation_from_worker( + &delegation(), + json!({"delegation_update": {"chain": [hop], "depth": 1}}), + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("delegation_update.chain[0].issuer"), + "{err}" + ); + } + + #[test] + fn no_token_and_no_update_returns_the_payload_unchanged() { + let back = + delegation_from_worker(&delegation(), json!({"target_name": "billing-api"})).unwrap(); + assert!(back.delegated_token.is_none()); + assert!(back.delegation_update.is_none()); + } +} diff --git a/crates/cpex-hosts-python/src/credentials.rs b/crates/cpex-hosts-python/src/credentials.rs index f626671e..0b7a713a 100644 --- a/crates/cpex-hosts-python/src/credentials.rs +++ b/crates/cpex-hosts-python/src/credentials.rs @@ -12,7 +12,7 @@ // out-of-process identity resolver or token delegator cannot do its job over // that channel. // -// This module is the narrow, documented exception `cpex-core`'s +// This module is the narrow, documented exception `praxis-policy-core`'s // `RawCredentialsExtension` points at: a dedicated DTO // carries the token as a plain string, built only on the capability-gated // dispatch path for the two hooks whose Python payload models a raw token at @@ -47,16 +47,19 @@ use std::collections::{HashMap, HashSet}; -use cpex_core::extensions::raw_credentials::{ +use praxis_policy_core::delegation::DelegationPayload; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::extensions::raw_credentials::{ DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole, }; -use cpex_core::extensions::Extensions; +use praxis_policy_core::hooks::payload::PluginPayload; +use praxis_policy_core::identity::{IdentityPayload, TokenSource}; use serde::Serialize; use serde_json::Value; +use crate::PayloadKind; use crate::conversion::payload_kind_for_hook; use crate::error::HostError; -use crate::PayloadKind; /// Task field the DTO rides on. Contract with `worker.py`'s /// `CREDENTIAL_FIELD`; both sides must agree verbatim. @@ -94,7 +97,7 @@ impl std::fmt::Debug for CredentialDto { } } -/// `credential.inbound` — built from `RawInboundToken`, for `identity_resolve`. +/// `credential.inbound`: built from `RawInboundToken`, for `identity.resolve`. /// /// The worker maps these onto `IdentityPayload`: `token` → `raw_token`, `kind` /// → `source`, `headers` → `headers` (with the plaintext scrubbed out of the @@ -132,8 +135,8 @@ impl std::fmt::Debug for InboundCredential { } } -/// `credential.delegated` — built from `RawDelegatedToken`, for -/// `token_delegate`. +/// `credential.delegated`: built from `RawDelegatedToken`, for +/// `token.delegate`. /// /// The worker maps `token` → `DelegationPayload.bearer_token`; the rest is /// audit and validation context. @@ -187,7 +190,7 @@ fn token_kind_wire_name(kind: &TokenKind) -> &'static str { /// Attach the `credential` object to a task, when the hook and the plugin's /// declared capabilities both call for it. /// -/// A no-op for every hook other than `identity_resolve` and `token_delegate`, +/// A no-op for every hook other than `identity.resolve` and `token.delegate`, /// and for any plugin that declared neither capability. pub fn attach_credential( task: &mut Value, @@ -230,11 +233,136 @@ pub fn attach_credential( _ => return Ok(()), }; + insert_dto(task, &dto) +} + +/// Attach the `credential` object for this invocation's payload. +/// +/// The entry point the adapter uses. PPE's own payloads carry the credential +/// themselves: `IdentityPayload` holds the inbound token (in `raw_token`, or in +/// its source header when a host deserialized it), and `DelegationPayload` +/// holds the caller's bearer token. For those the token is read from the +/// payload, because it is the credential the hook is about; an extensions +/// token could belong to a different principal. Any other payload goes +/// through [`attach_credential`] and the extensions view. +/// +/// The gate is the same either way: no declared capability means no DTO, and +/// a declared capability with no usable token is an error. +pub fn attach_credential_for_payload( + task: &mut Value, + hook_name: &str, + payload: &dyn PluginPayload, + extensions: &Extensions, + capabilities: &HashSet, +) -> Result<(), HostError> { + let any = payload.as_any(); + let dto = match payload_kind_for_hook(hook_name) { + PayloadKind::IdentityResolve => match any.downcast_ref::() { + Some(identity) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto { + inbound: Some(inbound_from_identity(identity, hook_name)?), + delegated: None, + }, + Some(_) => return Ok(()), + None => return attach_credential(task, hook_name, extensions, capabilities), + }, + // The bearer token on a PPE delegation payload is the caller's inbound + // credential, sourced from the inbound tokens PPE shows only to a + // plugin holding `read_inbound_credentials` (its own OAuth delegator + // declares exactly that). So that is the capability that gates it + // here too, rather than `read_delegated_tokens`, which names tokens + // already minted for an upstream. + PayloadKind::TokenDelegate => match any.downcast_ref::() { + Some(delegation) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto { + inbound: None, + delegated: Some(delegated_from_payload(delegation, hook_name)?), + }, + Some(_) => return Ok(()), + None => return attach_credential(task, hook_name, extensions, capabilities), + }, + _ => return attach_credential(task, hook_name, extensions, capabilities), + }; + insert_dto(task, &dto) +} + +/// `credential.inbound` from a PPE identity payload. +/// +/// The header map travels whole, credential headers and all: this is the +/// gated channel, and the worker scrubs the plaintext out of every value +/// before the plugin sees it. The public payload JSON carries no raw header +/// values by default (`credential_payloads::public_headers`). +fn inbound_from_identity( + identity: &IdentityPayload, + hook_name: &str, +) -> Result { + let token = crate::credential_payloads::inbound_token(identity).ok_or_else(|| { + HostError::Credential { + message: format!( + "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the identity \ + payload carries no credential, in `raw_token` or its source header. Refusing \ + to dispatch without the token it asked for" + ), + } + })?; + let source_header = crate::credential_payloads::credential_header_name(identity); + let mut headers = identity.headers().clone(); + if !headers + .keys() + .any(|name| name.eq_ignore_ascii_case(&source_header)) + { + headers.insert(source_header.clone(), token.clone()); + } + Ok(InboundCredential { + token, + source_header, + kind: token_source_kind(identity.source()).to_string(), + headers: Some(headers), + }) +} + +/// The `kind` wire name for a PPE token source. +/// +/// The worker reads `kind` only to fill `source` when the payload left it at +/// the default, and the payload already carries PPE's source, so this only +/// has to land in the right family. +fn token_source_kind(source: &TokenSource) -> &'static str { + match source { + TokenSource::SpiffeJwtSvid => "spiffe_jwt", + _ => "opaque", + } +} + +/// `credential.delegated` from a PPE delegation payload: the caller's bearer +/// token, for the worker to fold onto `DelegationPayload.bearer_token`. The +/// worker reads the delegation hook's token from this sub-object whichever +/// capability gated it. +fn delegated_from_payload( + delegation: &DelegationPayload, + hook_name: &str, +) -> Result { + let token = delegation.bearer_token(); + if token.trim().is_empty() { + return Err(HostError::Credential { + message: format!( + "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the delegation \ + payload carries no bearer token. Refusing to dispatch without the token it \ + asked for" + ), + }); + } + Ok(DelegatedCredential { + token: token.to_string(), + outbound_header: "Authorization".into(), + audience: delegation.target_audience().unwrap_or_default().to_string(), + scopes: delegation.required_permissions().to_vec(), + }) +} + +fn insert_dto(task: &mut Value, dto: &CredentialDto) -> Result<(), HostError> { let object = task.as_object_mut().ok_or_else(|| HostError::Protocol { message: "task must be a JSON object to carry a credential".into(), })?; - let serialized = serde_json::to_value(&dto).map_err(|e| HostError::Credential { + let serialized = serde_json::to_value(dto).map_err(|e| HostError::Credential { // The error names the failure, never the value. message: format!("could not serialize the credential DTO: {e}"), })?; @@ -318,7 +446,7 @@ fn inbound_from_token( }) } -/// The audience the `token_delegate` hook was invoked for, if the payload names +/// The audience the `token.delegate` hook was invoked for, if the payload names /// one. /// /// `DelegationPayload.target_audience` is optional, so a `None` here means the @@ -450,7 +578,7 @@ fn delegated_from_token( mod tests { use std::sync::Arc; - use cpex_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension}; + use praxis_policy_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension}; use super::*; @@ -494,12 +622,12 @@ mod tests { serde_json::json!({ "task_type": "load_and_run_hook" }) } - /// A `token_delegate` task whose payload names the audience the delegation + /// A `token.delegate` task whose payload names the audience the delegation /// is for — the shape `plugin.rs` actually builds. fn delegate_task(target_audience: &str) -> Value { serde_json::json!({ "task_type": "load_and_run_hook", - "hook_type": "token_delegate", + "hook_type": "token.delegate", "payload": { "target_name": "billing", "target_type": "tool", @@ -538,13 +666,13 @@ mod tests { } } - /// The delegated token a `token_delegate` dispatch would carry, or the + /// The delegated token a `token.delegate` dispatch would carry, or the /// fail-closed error. fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result { let mut task = delegate_task(target_audience); attach_credential( &mut task, - "token_delegate", + "token.delegate", extensions, &caps(&[CAP_READ_DELEGATED]), )?; @@ -561,7 +689,7 @@ mod tests { let mut task = task(); attach_credential( &mut task, - "identity_resolve", + "identity.resolve", &extensions_with_credentials(), &caps(&[CAP_READ_INBOUND]), ) @@ -582,7 +710,7 @@ mod tests { let mut task = task(); attach_credential( &mut task, - "identity_resolve", + "identity.resolve", &extensions_with_credentials(), &caps(&[]), ) @@ -609,7 +737,7 @@ mod tests { let mut declaring = task(); attach_credential( &mut declaring, - "identity_resolve", + "identity.resolve", &extensions, &caps(&[CAP_READ_INBOUND]), ) @@ -618,18 +746,22 @@ mod tests { let mut non_declaring = task(); attach_credential( &mut non_declaring, - "identity_resolve", + "identity.resolve", &extensions, &caps(&[]), ) .unwrap(); - assert!(serde_json::to_string(&declaring) - .unwrap() - .contains(INBOUND_SECRET)); - assert!(!serde_json::to_string(&non_declaring) - .unwrap() - .contains("INBOUND-SECRET-BYTES")); + assert!( + serde_json::to_string(&declaring) + .unwrap() + .contains(INBOUND_SECRET) + ); + assert!( + !serde_json::to_string(&non_declaring) + .unwrap() + .contains("INBOUND-SECRET-BYTES") + ); } #[test] @@ -641,7 +773,7 @@ mod tests { let mut identity = task(); attach_credential( &mut identity, - "identity_resolve", + "identity.resolve", &extensions, &caps(&[CAP_READ_DELEGATED]), ) @@ -651,7 +783,7 @@ mod tests { let mut delegate = task(); attach_credential( &mut delegate, - "token_delegate", + "token.delegate", &extensions, &caps(&[CAP_READ_INBOUND]), ) @@ -664,7 +796,7 @@ mod tests { let mut task = task(); attach_credential( &mut task, - "token_delegate", + "token.delegate", &extensions_with_credentials(), &caps(&[CAP_READ_DELEGATED]), ) @@ -688,7 +820,13 @@ mod tests { "prompt_pre_fetch", "resource_post_fetch", "cmf.tool_pre_invoke", + "cmf.prompt_pre_invoke", + "http.request", "some_custom_hook", + // The unprefixed names PPE no longer dispatches. A host that + // registers them gets the generic payload and no credential. + "identity_resolve", + "token_delegate", ] { let mut task = task(); attach_credential( @@ -715,7 +853,7 @@ mod tests { // as unauthenticated. let err = attach_credential( &mut task(), - "identity_resolve", + "identity.resolve", &Extensions::default(), &caps(&[CAP_READ_INBOUND]), ) @@ -741,7 +879,7 @@ mod tests { let err = attach_credential( &mut task(), - "identity_resolve", + "identity.resolve", &extensions, &caps(&[CAP_READ_INBOUND]), ) @@ -768,7 +906,7 @@ mod tests { let err = attach_credential( &mut task(), - "identity_resolve", + "identity.resolve", &extensions, &caps(&[CAP_READ_INBOUND]), ) @@ -792,7 +930,7 @@ mod tests { let err = attach_credential( &mut task(), - "token_delegate", + "token.delegate", &extensions, &caps(&[CAP_READ_DELEGATED]), ) @@ -814,14 +952,14 @@ mod tests { let err = attach_credential( &mut task(), - "identity_resolve", + "identity.resolve", &extensions, &caps(&[CAP_READ_INBOUND]), ) .expect_err("errors"); let message = err.to_string(); assert!( - message.contains("identity_resolve"), + message.contains("identity.resolve"), "the message should name the hook: {message}" ); assert!(message.contains("empty")); @@ -996,7 +1134,7 @@ mod tests { let mut task = task(); attach_credential( &mut task, - "identity_resolve", + "identity.resolve", &extensions, &caps(&[CAP_READ_INBOUND]), ) @@ -1009,7 +1147,7 @@ mod tests { let mut task = task(); attach_credential( &mut task, - "identity_resolve", + "identity.resolve", &extensions_with_credentials(), &caps(&[CAP_READ_INBOUND]), ) @@ -1055,7 +1193,7 @@ mod tests { let mut task = task(); attach_credential( &mut task, - "token_delegate", + "token.delegate", &extensions, &caps(&[CAP_READ_DELEGATED]), ) @@ -1254,7 +1392,7 @@ mod tests { }); attach_credential( &mut task, - "token_delegate", + "token.delegate", &extensions_from(tokens.clone()), &caps(&[CAP_READ_DELEGATED]), ) @@ -1296,4 +1434,158 @@ mod tests { "the worker reads task_data['credential']" ); } + + // --- PPE identity and delegation payloads --------------------------------- + + use praxis_policy_core::delegation::DelegationPayload; + use praxis_policy_core::identity::{IdentityPayload, TokenSource}; + + fn identity_payload() -> IdentityPayload { + IdentityPayload::new(INBOUND_SECRET, TokenSource::Bearer).with_headers(HashMap::from([ + ("authorization".into(), format!("Bearer {INBOUND_SECRET}")), + ("cookie".into(), "session=abc".into()), + ("x-tenant".into(), "acme".into()), + ])) + } + + #[test] + fn an_identity_payload_supplies_its_own_token_to_a_declaring_plugin() { + let mut task = task(); + // No raw-credentials extension: PPE dispatches identity.resolve with the + // credential on the payload. + attach_credential_for_payload( + &mut task, + "identity.resolve", + &identity_payload(), + &Extensions::default(), + &caps(&[CAP_READ_INBOUND]), + ) + .expect("the payload's token honors the capability"); + + let inbound = &task[CREDENTIAL_FIELD]["inbound"]; + assert_eq!(inbound["token"], INBOUND_SECRET); + assert_eq!(inbound["source_header"], "authorization"); + assert_eq!(inbound["kind"], "opaque"); + // The gated channel carries the whole header map; the worker scrubs it. + assert_eq!(inbound["headers"]["cookie"], "session=abc"); + assert_eq!(inbound["headers"]["x-tenant"], "acme"); + } + + #[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 mut task = task(); + attach_credential_for_payload( + &mut task, + "identity.resolve", + &payload, + &Extensions::default(), + &caps(&[CAP_READ_INBOUND]), + ) + .unwrap(); + assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET); + assert_eq!( + task[CREDENTIAL_FIELD]["inbound"]["source_header"], + "x-user-token" + ); + } + + #[test] + fn an_identity_payload_gives_a_non_declaring_plugin_nothing() { + let mut task = task(); + attach_credential_for_payload( + &mut task, + "identity.resolve", + &identity_payload(), + &extensions_with_credentials(), + &caps(&[CAP_READ_DELEGATED]), + ) + .unwrap(); + assert!(task.get(CREDENTIAL_FIELD).is_none()); + } + + #[test] + fn an_identity_payload_without_a_credential_fails_closed() { + let payload = IdentityPayload::new("", TokenSource::Bearer); + let mut task = task(); + // The extensions hold a token, but it is not the one this hook is + // about, so it is not substituted. + let err = attach_credential_for_payload( + &mut task, + "identity.resolve", + &payload, + &extensions_with_credentials(), + &caps(&[CAP_READ_INBOUND]), + ) + .unwrap_err(); + assert!(matches!(err, HostError::Credential { .. }), "{err:?}"); + assert!(task.get(CREDENTIAL_FIELD).is_none()); + } + + #[test] + fn a_delegation_payload_supplies_its_bearer_token_to_an_inbound_declaring_plugin() { + let payload = DelegationPayload::new(INBOUND_SECRET, "billing") + .with_target_audience("https://billing.example.com") + .with_required_permissions(vec!["read".into()]); + let mut gated = task(); + attach_credential_for_payload( + &mut gated, + "token.delegate", + &payload, + &Extensions::default(), + &caps(&[CAP_READ_INBOUND]), + ) + .unwrap(); + let delegated = &gated[CREDENTIAL_FIELD]["delegated"]; + assert_eq!(delegated["token"], INBOUND_SECRET); + assert_eq!(delegated["audience"], "https://billing.example.com"); + assert_eq!(delegated["scopes"], serde_json::json!(["read"])); + + // The bearer is an inbound credential, so the delegated-token + // capability alone does not unlock it. + let mut task = task(); + attach_credential_for_payload( + &mut task, + "token.delegate", + &payload, + &Extensions::default(), + &caps(&[CAP_READ_DELEGATED]), + ) + .unwrap(); + assert!(task.get(CREDENTIAL_FIELD).is_none()); + } + + #[test] + fn a_delegation_payload_without_a_bearer_token_fails_closed() { + let payload = DelegationPayload::new(" ", "billing"); + let mut task = task(); + let err = attach_credential_for_payload( + &mut task, + "token.delegate", + &payload, + &extensions_with_credentials(), + &caps(&[CAP_READ_INBOUND]), + ) + .unwrap_err(); + assert!(matches!(err, HostError::Credential { .. }), "{err:?}"); + } + + #[test] + fn a_legacy_payload_still_reads_the_extensions() { + let mut task = task(); + attach_credential_for_payload( + &mut task, + "identity.resolve", + &crate::legacy::IdentityResolvePayload::default(), + &extensions_with_credentials(), + &caps(&[CAP_READ_INBOUND]), + ) + .unwrap(); + assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET); + } } diff --git a/crates/cpex-hosts-python/src/error.rs b/crates/cpex-hosts-python/src/error.rs index c97eec29..a60f464f 100644 --- a/crates/cpex-hosts-python/src/error.rs +++ b/crates/cpex-hosts-python/src/error.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Ted Habeck // -// Host-internal errors and their mapping to `cpex_core::error::PluginError`. +// Host-internal errors and their mapping to `praxis_policy_core::error::PluginError`. // // The host distinguishes failure modes the executor cannot see — a venv that // would not build, a worker that died mid-flight, a task over the size cap — @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::fmt; -use cpex_core::error::PluginError; +use praxis_policy_core::error::PluginError; /// Cap on the stderr excerpt carried in `PluginError::Execution.details`. /// @@ -170,6 +170,14 @@ pub enum HostError { /// /// The message never contains token material. Credential { message: String }, + + /// A `cmf.*` hook could not be translated for a worker that predates + /// `cmf.*` hook support, so the host failed closed rather than dispatching + /// a payload the plugin would misread or skipping the plugin. + /// + /// Like `Protocol`, the message names shapes (part types, field names), + /// never payload values. + LegacyShim { hook: String, message: String }, } impl fmt::Display for HostError { @@ -204,6 +212,11 @@ impl fmt::Display for HostError { Self::WorkerError { message } => write!(f, "worker returned an error: {message}"), Self::Protocol { message } => write!(f, "protocol error: {message}"), Self::Credential { message } => write!(f, "credential error: {message}"), + Self::LegacyShim { hook, message } => write!( + f, + "hook '{hook}' cannot be served by this worker, which predates cmf hook \ + support: {message}" + ), } } } @@ -225,6 +238,7 @@ impl HostError { Self::WorkerError { .. } => "worker_error", Self::Protocol { .. } => "protocol_error", Self::Credential { .. } => "credential_error", + Self::LegacyShim { .. } => "legacy_shim_unsupported", } } @@ -267,7 +281,8 @@ impl HostError { | Self::WorkerDied { .. } | Self::WorkerError { .. } | Self::Protocol { .. } - | Self::Credential { .. } => {}, + | Self::Credential { .. } + | Self::LegacyShim { .. } => {}, } details } @@ -373,9 +388,11 @@ mod tests { let details = execution_details(pip_failure("", None)); assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::Null); - assert!(pip_failure("", None) - .to_string() - .contains("was killed by a signal")); + assert!( + pip_failure("", None) + .to_string() + .contains("was killed by a signal") + ); } /// Long pip output is cut to the tail, where the actual error lives, and diff --git a/crates/cpex-hosts-python/src/extensions.rs b/crates/cpex-hosts-python/src/extensions.rs index 73d2affb..ceb5bb4c 100644 --- a/crates/cpex-hosts-python/src/extensions.rs +++ b/crates/cpex-hosts-python/src/extensions.rs @@ -51,21 +51,22 @@ // // This channel carries non-secret context only. `raw_credentials` is never // serialized here — raw tokens travel the capability-gated DTO in the -// `credentials` module instead. Sensitive headers are stripped in both -// directions per `docs/specs/cmf-message-spec.md` §3.5; a plugin that needs a -// bearer token uses the credential path, not `http.request_headers`. +// `credentials` module instead. No raw header values cross outbound by +// default; known credential names are removed from worker-returned edits. A +// plugin that needs a bearer token uses the credential path. use std::collections::HashMap; -use cpex_core::extensions::container::{chain_extends, OwnedExtensions}; -use cpex_core::extensions::delegation::DelegationExtension; -use cpex_core::extensions::guarded::Guarded; -use cpex_core::extensions::http::HttpExtension; -use cpex_core::extensions::security::SecurityExtension; -use cpex_core::extensions::Extensions; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::extensions::container::{OwnedExtensions, chain_extends}; +use praxis_policy_core::extensions::delegation::DelegationExtension; +use praxis_policy_core::extensions::guarded::Guarded; +use praxis_policy_core::extensions::http::HttpExtension; +use praxis_policy_core::extensions::security::SecurityExtension; use serde_json::Value; use tracing::warn; +use crate::credential_payloads::{CREDENTIAL_HEADERS, is_public_header}; use crate::error::HostError; /// Task field the inbound extensions ride on. Contract with `worker.py`, which @@ -84,25 +85,24 @@ pub const EXTENSIONS_FIELD: &str = "extensions"; /// exact same way its in-process equivalent does. pub const MODIFIED_EXTENSIONS_FIELD: &str = "modified_extensions"; -/// Headers stripped in both directions (spec §3.5). -/// -/// Matched case-insensitively: HTTP header names are case-insensitive, and -/// `HttpExtension`'s own accessors look up that way, so a plugin sending -/// `authorization` must not slip past a case-sensitive compare. -/// `set-cookie` is included because the doc comment on `sanitize_http` promises -/// it and the response map is the one that carries it. It was previously absent -/// while both strip tests asserted through `is_sensitive` itself — a tautology -/// that passed either way, which is what let the gap survive. -const SENSITIVE_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie", "x-api-key"]; - -/// True when `name` is a header this channel must not carry. +/// True when a worker-returned header is credential-bearing by convention. +/// The outbound path is stricter: it forwards no raw header values by default. fn is_sensitive(name: &str) -> bool { - SENSITIVE_HEADERS + CREDENTIAL_HEADERS .iter() .any(|s| name.eq_ignore_ascii_case(s)) } -/// Drop sensitive entries from a header map, leaving the rest untouched. +/// Keep only individually approved and value-validated headers on the wire. +fn public_http_headers(headers: &HashMap) -> HashMap { + headers + .iter() + .filter(|(name, value)| is_public_header(name, value, None, None)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() +} + +/// Drop known credential headers from worker-returned modifications. fn strip_sensitive(headers: &HashMap) -> HashMap { headers .iter() @@ -111,16 +111,16 @@ fn strip_sensitive(headers: &HashMap) -> HashMap .collect() } -/// Copy an `HttpExtension` with sensitive headers removed from both maps. +/// Copy an `HttpExtension` with only approved outbound headers. /// -/// Both maps are scrubbed: `response_headers` can carry a `Set-Cookie` or an -/// upstream `Authorization` echo just as `request_headers` carries the inbound -/// credential. The request line is copied through unchanged — it is not -/// credential material, and the plugin needs it to make routing decisions. +/// Both raw header maps are default-denied. Request-line fields still cross +/// because the plugin needs them for routing decisions. fn sanitize_http(http: &HttpExtension) -> HttpExtension { HttpExtension { - request_headers: strip_sensitive(&http.request_headers), - response_headers: strip_sensitive(&http.response_headers), + request_headers: public_http_headers(&http.request_headers), + secret_headers: http.secret_headers.clone(), + response_headers: public_http_headers(&http.response_headers), + status: http.status, method: http.method.clone(), path: http.path.clone(), host: http.host.clone(), @@ -130,8 +130,8 @@ fn sanitize_http(http: &HttpExtension) -> HttpExtension { /// Build the returned `http` value: worker headers, canonical request line. /// -/// The worker's return contributes header maps only. `method`, `path`, `host`, -/// and `scheme` are taken from `inbound` — the value the executor issued — +/// The worker's return contributes header maps only. `status`, `method`, `path`, +/// `host`, and `scheme` are taken from `inbound` (the value the executor issued) /// because policies gate on them (CHANGELOG 0.2.2) and `write_headers` does not /// authorize rewriting request identity. `host` especially must trace back to a /// validated authority, which a worker's JSON is not. @@ -144,7 +144,11 @@ fn sanitize_http(http: &HttpExtension) -> HttpExtension { fn returned_http(returned: &HttpExtension, inbound: Option<&HttpExtension>) -> HttpExtension { HttpExtension { request_headers: strip_sensitive(&returned.request_headers), + secret_headers: inbound + .map(|h| h.secret_headers.clone()) + .unwrap_or_default(), response_headers: strip_sensitive(&returned.response_headers), + status: inbound.and_then(|h| h.status), method: inbound.and_then(|h| h.method.clone()), path: inbound.and_then(|h| h.path.clone()), host: inbound.and_then(|h| h.host.clone()), @@ -187,8 +191,8 @@ pub fn attach_extensions(task: &mut Value, extensions: &Extensions) -> Result<() /// /// Slots serialize through their own serde impls — those are the source of /// truth for sub-field shape, so this function stays correct as the extension -/// structs evolve. Only `http` is rewritten on the way out, to strip sensitive -/// headers. +/// structs evolve. Only `http` is rewritten on the way out, to default-deny +/// unvalidated raw header values. fn to_wire(extensions: &Extensions) -> Result { let mut map = serde_json::Map::new(); @@ -319,7 +323,7 @@ pub fn owned_from_returned_slot( // The three writable-but-gated slots are honored only when the executor // issued the matching write token on the inbound view. The token is the // host's *only* evidence that the plugin declared the write capability — - // `WriteToken::new()` is `pub(crate)` to `cpex-core`, so this crate cannot + // `WriteToken::new()` is `pub(crate)` to `praxis-policy-core`, so this crate cannot // mint one, and a token can never be forged out of worker JSON. An edit // without the token is dropped and the inbound value stands. // @@ -334,13 +338,13 @@ pub fn owned_from_returned_slot( // Guarded — `write_headers`. Headers only: the request line // (`method`/`path`/`host`/`scheme`) is preserved from the inbound value, // which policies gate on and this capability does not cover. - if let Some(http) = slot::(object, "http")? { - if inbound.http_write_token.is_some() { - // Strip on the way back too. A plugin cannot inject a credential - // header into the pipeline through its return value. - owned.http = Some(Guarded::new(returned_http(&http, inbound.http.as_deref()))); - applied = true; - } + if let Some(http) = slot::(object, "http")? + && inbound.http_write_token.is_some() + { + // Strip on the way back too. A plugin cannot inject a credential + // header into the pipeline through its return value. + owned.http = Some(Guarded::new(returned_http(&http, inbound.http.as_deref()))); + applied = true; } // Monotonic — `append_labels`. Labels *only*. Everything else on the slot @@ -349,21 +353,21 @@ pub fn owned_from_returned_slot( // identity — otherwise `append_labels` alone would let a plugin rewrite the // authenticated principal it was authenticated as. The executor re-checks // `before ⊆ after` and drops a removal. - if let Some(security) = slot::(object, "security")? { - if inbound.labels_write_token.is_some() { - let mut merged = inbound - .security - .as_deref() - .cloned() - .unwrap_or_else(SecurityExtension::default); - // Append-only, and only into the set the plugin was shown. Assigning - // the returned set would let a shorter one read as a removal. - for label in security.labels.iter() { - merged.add_label(label.clone()); - } - owned.security = Some(merged); - applied = true; + if let Some(security) = slot::(object, "security")? + && inbound.labels_write_token.is_some() + { + let mut merged = inbound + .security + .as_deref() + .cloned() + .unwrap_or_else(SecurityExtension::default); + // Append-only, and only into the set the plugin was shown. Assigning + // the returned set would let a shorter one read as a removal. + for label in security.labels.iter() { + merged.add_label(label.clone()); } + owned.security = Some(merged); + applied = true; } // Monotonic — `append_delegation`. Validated rather than accepted blind: the @@ -372,25 +376,25 @@ pub fn owned_from_returned_slot( // the hop that recorded a scope narrowing widens effective authority — so the // whole edit is refused and the inbound chain stands. `depth` and `delegated` // are recomputed by the executor from the merged chain, never trusted here. - if let Some(delegation) = slot::(object, "delegation")? { - if inbound.delegation_write_token.is_some() { - let inbound_chain = inbound - .delegation - .as_deref() - .map(|d| d.chain.as_slice()) - .unwrap_or_default(); - if chain_extends(inbound_chain, &delegation.chain) { - owned.delegation = Some(delegation); - applied = true; - } else { - warn!( - inbound_hops = inbound_chain.len(), - returned_hops = delegation.chain.len(), - "dropping a returned delegation chain that does not extend the \ + if let Some(delegation) = slot::(object, "delegation")? + && inbound.delegation_write_token.is_some() + { + let inbound_chain = inbound + .delegation + .as_deref() + .map(|d| d.chain.as_slice()) + .unwrap_or_default(); + if chain_extends(inbound_chain, &delegation.chain) { + owned.delegation = Some(delegation); + applied = true; + } else { + warn!( + inbound_hops = inbound_chain.len(), + returned_hops = delegation.chain.len(), + "dropping a returned delegation chain that does not extend the \ one the plugin was given — an append-only chain cannot be \ shortened or rewritten" - ); - } + ); } } @@ -430,17 +434,67 @@ mod tests { use std::collections::HashSet; use std::sync::Arc; - use cpex_core::extensions::agent::AgentExtension; - use cpex_core::extensions::monotonic::MonotonicSet; + use praxis_policy_core::extensions::agent::AgentExtension; + use praxis_policy_core::extensions::monotonic::MonotonicSet; use super::*; + #[test] + fn secret_assertion_provenance_survives_python_header_round_trips() { + use praxis_policy_core::assertions::config::OnMissing; + use praxis_policy_core::assertions::resolved::{ + ResolvedContract, ResolvedHeader, ResolvedSource, + }; + use praxis_policy_core::assertions::source::SourcePath; + use praxis_policy_core::assertions::{AssertionLevel, Direction, apply}; + + // Provenance must survive even after the wire filter drops the header. + let secret = "f47ac10b-58cc-4372-a567-0e02b2c3d479"; + let contract = ResolvedContract { + headers: vec![ResolvedHeader { + name: "X-Request-Id".into(), + lowercase: "x-request-id".into(), + source: ResolvedSource::From(SourcePath::Secret("key".into())), + on_missing: OnMissing::Deny, + encode: None, + declared_in: "global".into(), + level: AssertionLevel::Global, + overrode: None, + }], + ..Default::default() + }; + let mut ext = Extensions { + http: Some(Arc::new(HttpExtension::default())), + ..Default::default() + }; + apply( + &contract, + &[("X-Request-Id".into(), secret.into())], + &mut ext, + Direction::Request, + ); + let inbound = ext.http.as_deref().unwrap(); + let mut outbound = sanitize_http(inbound); + assert!(outbound.request_headers.is_empty()); + outbound.set_request_header("X-Request-Id", secret); + let mut worker = HttpExtension::default(); + worker.set_request_header("x-request-id", secret); + worker.set_response_header("Content-Type", "application/json"); + let returned = returned_http(&worker, Some(inbound)); + for http in [outbound, returned] { + let wire = serde_json::to_string(&http).unwrap(); + assert!(!wire.contains(secret), "{wire}"); + assert!(wire.contains(""), "{wire}"); + } + } + fn http_with_headers() -> HttpExtension { let mut http = HttpExtension::default(); http.set_request_header("Authorization", "Bearer secret-token"); http.set_request_header("Cookie", "session=abc"); http.set_request_header("X-API-Key", "key-123"); http.set_request_header("X-Request-Id", "req-1"); + http.set_request_header("X-Amzn-Oidc-Data", "gateway-jwt"); http.set_response_header("Set-Cookie", "s=1"); http.set_response_header("Authorization", "Bearer echoed"); http.set_response_header("Content-Type", "application/json"); @@ -482,59 +536,26 @@ mod tests { assert!(wire.contains_key("security"), "security slot present"); } - /// Assert a header map contains none of `names`, case-insensitively. - /// - /// Takes **literal** names rather than calling `is_sensitive`. The tests - /// below previously asserted `!is_sensitive(name)` over the surviving keys — - /// the same function under test on both sides, so the assertion held no - /// matter what `SENSITIVE_HEADERS` contained. That is what let `set-cookie` - /// be missing from the list while the comment claimed it was there, with - /// both strip tests green. - fn assert_absent(headers: &serde_json::Map, names: &[&str]) { - for name in names { - assert!( - !headers.keys().any(|k| k.eq_ignore_ascii_case(name)), - "header '{name}' must not cross the process boundary; got {:?}", - headers.keys().collect::>() - ); - } - } - #[test] - fn sensitive_request_headers_are_stripped_but_others_survive() { + fn no_unvalidated_request_header_reaches_the_worker() { let mut task = serde_json::json!({}); attach_extensions(&mut task, &populated()).expect("serializing"); let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"] .as_object() - .expect("request_headers is an object") - .clone(); - - assert_absent(&headers, &["Authorization", "Cookie", "X-API-Key"]); - assert_eq!( - headers.get("X-Request-Id").and_then(Value::as_str), - Some("req-1"), - "a non-sensitive header is preserved verbatim" - ); + .expect("request_headers is an object"); + assert!(headers.is_empty(), "raw request headers need validation"); } #[test] - fn sensitive_response_headers_are_stripped_too() { + fn no_unvalidated_response_header_reaches_the_worker() { let mut task = serde_json::json!({}); attach_extensions(&mut task, &populated()).expect("serializing"); let headers = task[EXTENSIONS_FIELD]["http"]["response_headers"] .as_object() - .expect("response_headers is an object") - .clone(); - - // `Set-Cookie` is the one the module comment always claimed to strip. - // This assertion fails until it is actually in `SENSITIVE_HEADERS`. - assert_absent(&headers, &["Set-Cookie", "Authorization"]); - assert_eq!( - headers.get("Content-Type").and_then(Value::as_str), - Some("application/json"), - ); + .expect("response_headers is an object"); + assert!(headers.is_empty(), "raw response headers need validation"); } #[test] @@ -561,6 +582,23 @@ mod tests { ); } + #[test] + fn gateway_credential_names_are_stripped_from_worker_edits() { + let mut http = HttpExtension::default(); + http.set_request_header("Proxy-Authorization", "Bearer proxy-secret"); + http.set_request_header("X-Amzn-Oidc-Data", "gateway-jwt"); + http.set_response_header("X-Auth-Token", "response-secret"); + http.set_response_header("X-Trace", "trace-only"); + + let returned = returned_http(&http, None); + assert!(returned.request_headers.is_empty()); + assert_eq!( + returned.response_headers.get("X-Trace").map(String::as_str), + Some("trace-only") + ); + assert_eq!(returned.response_headers.len(), 1); + } + #[test] fn lowercase_sensitive_headers_are_stripped() { // HTTP header names are case-insensitive; a case-sensitive filter would @@ -607,7 +645,7 @@ mod tests { #[test] fn raw_credentials_never_appear_on_the_wire() { - use cpex_core::extensions::raw_credentials::{ + use praxis_policy_core::extensions::raw_credentials::{ RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; @@ -743,7 +781,7 @@ mod tests { // The three gated slots below can only be *accepted* when the executor // issued a write token, and `WriteToken::new()` is `pub(crate)` to - // `cpex-core` — this crate cannot mint one even in a test. That is the + // `praxis-policy-core`, so this crate cannot mint one even in a test. That is the // security property, so these tests assert the deny side here and the // accept side is covered end-to-end through the real executor in // `tests/extensions_merge_e2e.rs`, where tokens come from capabilities. @@ -944,7 +982,7 @@ mod tests { // itself is asserted directly against the shared `chain_extends` // predicate the merge uses — the accept path needs a real executor // token and lives in `tests/extensions_merge_e2e.rs`. - use cpex_core::extensions::delegation::DelegationHop; + use praxis_policy_core::extensions::delegation::DelegationHop; let hop = |id: &str, scopes: Vec<&str>| DelegationHop { subject_id: id.into(), diff --git a/crates/cpex-hosts-python/src/factory.rs b/crates/cpex-hosts-python/src/factory.rs index 72f7c7b3..0b8c42b2 100644 --- a/crates/cpex-hosts-python/src/factory.rs +++ b/crates/cpex-hosts-python/src/factory.rs @@ -5,14 +5,14 @@ // // The `isolated_venv` plugin factory. // -// Hosts register this before `PluginManager::from_config()`. For each config -// entry whose `kind:` is `isolated_venv`, the manager calls `create()`, which +// Hosts register this before `PolicyEngine::from_config()`. For each config +// entry whose `kind:` is `isolated_venv`, the engine calls `create()`, which // parses the venv settings out of the entry's opaque `config` map and returns // the plugin plus one handler per declared hook. use std::sync::Arc; -use cpex_core::{ +use praxis_policy_core::{ error::PluginError, factory::{PluginFactory, PluginInstance}, plugin::PluginConfig, @@ -50,7 +50,7 @@ impl PluginFactory for IsolatedVenvFactory { return Err(PluginError::Config { message: format!( "plugin '{}' (isolated_venv): `hooks:` must list at least one hook \ - for the Python plugin to handle (e.g. tool_pre_invoke)", + for the Python plugin to handle (e.g. cmf.tool_pre_invoke)", config.name ), } @@ -72,15 +72,18 @@ impl PluginFactory for IsolatedVenvFactory { } // Parsing happens here rather than in `initialize()` so a malformed - // entry fails at config load — while the manager can still report - // which plugin was bad — instead of midway through a rollback. + // entry fails at config load, while the engine can still report + // which plugin was bad, instead of midway through a rollback. let plugin = Arc::new(IsolatedPythonPlugin::from_config(config)?); let handlers: Vec<_> = config .hooks .iter() .map( - |hook| -> (&'static str, Arc) { + |hook| -> ( + &'static str, + Arc, + ) { // The registry keys handlers by `&'static str`, and hook names // arrive as owned Strings from YAML. Leaking is what // audit-logger does: the set is bounded by config size and @@ -100,9 +103,9 @@ impl PluginFactory for IsolatedVenvFactory { #[cfg(test)] mod tests { - use cpex_core::config::CpexConfig; - use cpex_core::factory::PluginFactoryRegistry; - use cpex_core::manager::PluginManager; + use praxis_policy_core::config::PolicyConfig; + use praxis_policy_core::engine::PolicyEngine; + use praxis_policy_core::factory::PluginFactoryRegistry; use super::*; @@ -111,10 +114,12 @@ mod tests { /// `/plugins` — see `plugin::DEFAULT_PLUGIN_DIR`. fn minimal_config_yaml() -> &'static str { r#" +engine_settings: + dispatch: hooks plugins: - name: pii-filter kind: isolated_venv - hooks: [tool_pre_invoke] + hooks: [cmf.tool_pre_invoke] config: class_name: my_pkg.filters.PiiFilter "# @@ -135,12 +140,12 @@ plugins: #[test] fn loading_an_isolated_venv_config_succeeds() { - let config: CpexConfig = serde_yaml::from_str(minimal_config_yaml()).expect("valid YAML"); + let config: PolicyConfig = serde_yaml::from_str(minimal_config_yaml()).expect("valid YAML"); let factories = registry(); // The load path is what would raise "no factory registered for plugin // kind 'isolated_venv'" if registration did not take. - PluginManager::from_config(config, &factories) + PolicyEngine::from_config(config, &factories) .expect("config loads with the factory registered"); } @@ -149,16 +154,18 @@ plugins: // The block carries only the now-ignored `plugin_dirs`, so this also // covers that an ignored key does not mask the missing required one. let yaml = r#" +engine_settings: + dispatch: hooks plugins: - name: broken kind: isolated_venv - hooks: [tool_pre_invoke] + hooks: [cmf.tool_pre_invoke] config: plugin_dirs: ["./plugins"] "#; - let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML"); - // `PluginManager` is not Debug, so `expect_err` is unavailable here. - let Err(err) = PluginManager::from_config(config, ®istry()) else { + let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML"); + // `PolicyEngine` is not Debug, so `expect_err` is unavailable here. + let Err(err) = PolicyEngine::from_config(config, ®istry()) else { panic!("a config block without class_name must be rejected"); }; @@ -176,6 +183,8 @@ plugins: #[test] fn empty_hooks_list_is_rejected() { let yaml = r#" +engine_settings: + dispatch: hooks plugins: - name: no-hooks kind: isolated_venv @@ -183,8 +192,8 @@ plugins: config: class_name: my_pkg.filters.PiiFilter "#; - let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML"); - let Err(err) = PluginManager::from_config(config, ®istry()) else { + let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML"); + let Err(err) = PolicyEngine::from_config(config, ®istry()) else { panic!("a plugin declaring no hooks can never be invoked"); }; assert!(matches!(*err, PluginError::Config { .. })); @@ -197,7 +206,7 @@ plugins: let with_key = PluginConfig { name: "legacy".into(), kind: KIND.into(), - hooks: vec!["tool_pre_invoke".into()], + hooks: vec!["cmf.tool_pre_invoke".into()], config: Some(serde_json::json!({ "class_name": "my_pkg.P", "plugin_dirs": ["/somewhere"], @@ -229,16 +238,18 @@ plugins: // hit a load failure — the key is ignored (with a warning), not // rejected. A hard error here would break every existing config. let yaml = r#" +engine_settings: + dispatch: hooks plugins: - name: legacy-dirs kind: isolated_venv - hooks: [tool_pre_invoke] + hooks: [cmf.tool_pre_invoke] config: class_name: my_pkg.filters.PiiFilter plugin_dirs: ["/somewhere/else"] "#; - let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML"); - PluginManager::from_config(config, ®istry()) + let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML"); + PolicyEngine::from_config(config, ®istry()) .expect("an ignored plugin_dirs key must not fail the load"); } @@ -248,9 +259,9 @@ plugins: name: "multi".into(), kind: KIND.into(), hooks: vec![ - "tool_pre_invoke".into(), - "tool_post_invoke".into(), "cmf.tool_pre_invoke".into(), + "identity.resolve".into(), + "token.delegate".into(), ], config: Some(serde_json::json!({ "class_name": "my_pkg.Multi" })), ..Default::default() @@ -262,7 +273,90 @@ plugins: assert_eq!(instance.handlers.len(), 3); let names: Vec<&str> = instance.handlers.iter().map(|(n, _)| *n).collect(); - assert!(names.contains(&"tool_pre_invoke")); assert!(names.contains(&"cmf.tool_pre_invoke")); + assert!(names.contains(&"identity.resolve")); + assert!(names.contains(&"token.delegate")); + } + + #[test] + fn handlers_pass_the_engine_family_check_on_every_dispatched_hook_family() { + // The registry refuses a handler whose reported family differs from the + // hook's. A handler reporting the bare hook name would fail on every + // `cmf.*` hook, whose family is `cmf`. + let yaml = r#" +engine_settings: + dispatch: hooks +plugins: + - name: py-everything + kind: isolated_venv + hooks: + - cmf.tool_pre_invoke + - cmf.prompt_pre_invoke + - identity.resolve + - token.delegate + config: + class_name: my_pkg.Everything +"#; + let config = praxis_policy_core::config::parse_config(yaml).expect("valid config"); + let engine = PolicyEngine::from_config(config, ®istry()) + .expect("every dispatched hook family accepts the isolated_venv handler"); + for hook in [ + "cmf.tool_pre_invoke", + "cmf.prompt_pre_invoke", + "identity.resolve", + "token.delegate", + ] { + assert!(engine.has_hooks_for(hook), "no handler on {hook}"); + } + } + + #[test] + fn a_top_level_plugin_dirs_key_fails_the_load() { + // PPE closed the document's key set. Only the in-plugin `config:` key + // is tolerated (with a warning); the top-level one is an error naming + // its replacement. + let yaml = r#" +plugin_dirs: ["./plugins"] +engine_settings: + dispatch: hooks +plugins: + - name: legacy-dirs + kind: isolated_venv + hooks: [cmf.tool_pre_invoke] + config: + class_name: my_pkg.filters.PiiFilter +"#; + let err = praxis_policy_core::config::parse_config(yaml) + .expect_err("a top-level plugin_dirs key must be rejected"); + assert!( + matches!(*err, PluginError::Config { .. }), + "expected a Config error, got: {err:?}" + ); + let msg = err.to_string(); + assert!( + msg.contains("plugin_dirs"), + "the error should name the key: {msg}" + ); + assert!( + msg.contains("register_factory"), + "the error should name the replacement: {msg}" + ); + } + + #[test] + fn a_legacy_hook_name_fails_the_load() { + let yaml = r#" +engine_settings: + dispatch: hooks +plugins: + - name: legacy-hook + kind: isolated_venv + hooks: [identity_resolve] + config: + class_name: my_pkg.Resolver +"#; + let err = praxis_policy_core::config::parse_config(yaml) + .expect_err("PPE does not dispatch the unprefixed identity hook"); + assert!(err.to_string().contains("identity.resolve"), "{err}"); } } diff --git a/crates/cpex-hosts-python/src/legacy/cmf_shim.rs b/crates/cpex-hosts-python/src/legacy/cmf_shim.rs new file mode 100644 index 00000000..8fc161d3 --- /dev/null +++ b/crates/cpex-hosts-python/src/legacy/cmf_shim.rs @@ -0,0 +1,1801 @@ +// Location: ./crates/cpex-hosts-python/src/legacy/cmf_shim.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Ted Habeck +// +// Payload shim between PPE's `cmf.*` hooks and the legacy typed hooks. +// +// # Why this exists +// +// PPE dispatches tool, prompt and resource traffic only on `cmf.*` hooks, with +// a CMF `MessagePayload`. Python workers that predate `cmf.*` hook support +// (the released 0.1.x SDK) register no `cmf.*` hook in the worker, and their +// plugins implement `tool_pre_invoke` and friends against the typed payloads +// (`ToolPreInvokePayload` and so on). Without this module such a plugin cannot +// run on PPE at all. +// +// So, for a worker that does not claim the `cmf_hooks` handshake feature, the +// adapter routes each of the six tool, prompt and resource `cmf.*` hooks +// through here: +// +// outbound: the CMF message ──> the legacy typed payload + legacy hook name +// inbound: a modified legacy payload ──> the same CMF message with only the +// fields the legacy payload carries replaced +// +// Allow, deny and violations pass through untouched; only `modified_payload` +// is translated. A message the legacy payload cannot represent fails closed +// with `HostError::LegacyShim`, never a silent allow. +// +// # Field decisions +// +// Every field of the CMF parts the shim reads. "sent" fields reach the plugin +// and are mapped back. "kept" fields are not sent and survive unchanged, which +// is safe because they neither qualify what is authorized nor carry content a +// plugin could need to rewrite. "refuse" fields are security-relevant and have +// no legacy slot, so a message that sets them fails closed. +// +// part field decision +// ---------------- ------------------- ------------------------------------- +// tool_call tool_call_id kept: correlation only +// name, arguments sent +// namespace refuse when non-empty: a plugin that +// authorizes by bare name would approve +// the same name in any namespace +// tool_result tool_call_id kept: correlation only +// tool_name, content sent (structured content travels +// inside `content` as is) +// is_error refuse when true unless `content` +// carries `"isError": true`; a modified +// result must keep it, and one that +// sets it sets the flag +// prompt_request prompt_request_id kept: correlation only +// name, arguments sent +// server_id refuse when non-empty: same reason as +// tool namespace +// prompt_result prompt_request_id kept: correlation only +// prompt_name sent +// messages sent (role and text; per-message +// `schema_version` and `channel` kept) +// content refuse when non-empty: rendered text +// the plugin cannot see or redact +// is_error refuse when true: no legacy slot +// error_message refuse when non-empty: same as content +// resource resource_request_id sent on post-fetch as `id`, else kept +// uri sent +// name, description kept: descriptive labels +// resource_type kept: the URI is the address +// annotations kept: labels that only restrict +// downstream, never widen +// version refuse when non-empty: selects which +// revision the URI resolves to +// content sent on post-fetch as `text`; on +// pre-fetch a URI rewrite refuses if set +// blob refuse when set +// mime_type sent on post-fetch; on pre-fetch a +// URI rewrite refuses if set +// size_bytes not sent; recomputed when post-fetch +// text changes; on pre-fetch a URI +// rewrite refuses if set +// resource_ref resource_request_id kept: correlation only +// uri sent +// name, resource_type kept: descriptive +// range_start/end, kept: they narrow what is read within +// selector the URI the plugin approved +// any other part (the whole part) post hooks: refuse unless it is a +// text or thinking part with no text. +// The plugin never sees it, so a PII +// filter would pass result content +// through unredacted. +// pre hooks: kept. The plugin +// authorizes the call, request or URI +// it sees, and narration beside it +// goes back unchanged. A worker that +// claims `cmf_hooks` receives the +// whole message instead +// +// # Removing it +// +// Once every supported worker claims `cmf_hooks`, delete this file, its +// `pub mod` line in `legacy/mod.rs`, and the `shim` branch in +// `PythonHookAdapter::invoke` and `build_task`. Nothing else depends on it. + +use std::collections::HashMap; + +use praxis_policy_core::cmf::constants::{ + HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, + HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, + HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, +}; +use praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Resource, Role}; +use praxis_policy_core::hooks::payload::PluginPayload; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use super::payloads::{ + PromptPostFetchPayload, PromptPreFetchPayload, ResourcePostFetchPayload, + ResourcePreFetchPayload, ToolPostInvokePayload, ToolPreInvokePayload, +}; +use crate::error::HostError; + +/// The six `cmf.*` hooks the shim translates, and the legacy hook each one +/// reaches the worker as. +const SHIMMED_HOOKS: &[(&str, Shim)] = &[ + (HOOK_CMF_TOOL_PRE_INVOKE, Shim::ToolPreInvoke), + (HOOK_CMF_TOOL_POST_INVOKE, Shim::ToolPostInvoke), + (HOOK_CMF_PROMPT_PRE_INVOKE, Shim::PromptPreFetch), + (HOOK_CMF_PROMPT_POST_INVOKE, Shim::PromptPostFetch), + (HOOK_CMF_RESOURCE_PRE_FETCH, Shim::ResourcePreFetch), + (HOOK_CMF_RESOURCE_POST_FETCH, Shim::ResourcePostFetch), +]; + +/// Which legacy hook a shimmed `cmf.*` hook maps to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Shim { + ToolPreInvoke, + ToolPostInvoke, + PromptPreFetch, + PromptPostFetch, + ResourcePreFetch, + ResourcePostFetch, +} + +impl Shim { + fn for_hook(hook_name: &str) -> Option { + SHIMMED_HOOKS + .iter() + .find(|(name, _)| *name == hook_name) + .map(|(_, shim)| *shim) + } + + /// The legacy hook name: the `hook_type` the worker dispatches on and the + /// method name a 0.1.x plugin implements. + fn legacy_hook(self) -> &'static str { + match self { + Self::ToolPreInvoke => "tool_pre_invoke", + Self::ToolPostInvoke => "tool_post_invoke", + Self::PromptPreFetch => "prompt_pre_fetch", + Self::PromptPostFetch => "prompt_post_fetch", + Self::ResourcePreFetch => "resource_pre_fetch", + Self::ResourcePostFetch => "resource_post_fetch", + } + } + + /// Whether this is a post hook, whose result content a plugin may need to + /// inspect or redact in full. + fn is_post(self) -> bool { + matches!( + self, + Self::ToolPostInvoke | Self::PromptPostFetch | Self::ResourcePostFetch + ) + } + + /// The CMF content part the legacy payload is built from, for diagnostics. + fn part_name(self) -> &'static str { + match self { + Self::ToolPreInvoke => "tool_call", + Self::ToolPostInvoke => "tool_result", + Self::PromptPreFetch => "prompt_request", + Self::PromptPostFetch => "prompt_result", + Self::ResourcePreFetch => "resource or resource_ref", + Self::ResourcePostFetch => "resource", + } + } + + /// Whether `part` is the kind this hook's legacy payload is built from. + fn matches(self, part: &ContentPart) -> bool { + match self { + Self::ToolPreInvoke => matches!(part, ContentPart::ToolCall { .. }), + Self::ToolPostInvoke => matches!(part, ContentPart::ToolResult { .. }), + Self::PromptPreFetch => matches!(part, ContentPart::PromptRequest { .. }), + Self::PromptPostFetch => matches!(part, ContentPart::PromptResult { .. }), + // A pre-fetch only needs the URI, which both resource parts carry. + // A post-fetch needs the content, which only `resource` carries; a + // `resource_ref` there is counted so it fails with a clear reason + // rather than as "no resource part". + Self::ResourcePreFetch | Self::ResourcePostFetch => matches!( + part, + ContentPart::Resource { .. } | ContentPart::ResourceRef { .. } + ), + } + } +} + +/// Whether `hook_name` is a `cmf.*` hook PPE dispatches. +/// +/// Every one of these needs either a worker that claims `cmf_hooks` or the +/// shim. The two LLM hooks are included: they have no legacy equivalent, so a +/// worker without `cmf_hooks` cannot serve them and [`to_legacy`] refuses. +pub fn is_cmf_hook(hook_name: &str) -> bool { + Shim::for_hook(hook_name).is_some() + || hook_name == HOOK_CMF_LLM_INPUT + || hook_name == HOOK_CMF_LLM_OUTPUT +} + +/// The legacy hook name a shimmed `cmf.*` hook reaches the worker as, or +/// `None` for any other name. +pub fn legacy_hook_name(hook_name: &str) -> Option<&'static str> { + Shim::for_hook(hook_name).map(Shim::legacy_hook) +} + +/// One translated invocation: the legacy payload to send, plus what is needed +/// to map a modified legacy payload back onto the original message. +#[derive(Debug)] +pub struct ShimmedPayload { + shim: Shim, + hook_name: String, + /// The message as dispatched. A modified payload is applied to a clone of + /// it, so every part and field the legacy payload does not carry survives. + original: MessagePayload, + /// Index of the content part the legacy payload was built from. + part: usize, + json: Value, +} + +impl ShimmedPayload { + /// The legacy hook name to send as the task's `hook_type`. + pub fn legacy_hook(&self) -> &'static str { + self.shim.legacy_hook() + } + + /// The legacy payload JSON, in the shape the worker's `json_to_payload` + /// rebuilds into the matching Pydantic model. + pub fn payload(&self) -> &Value { + &self.json + } + + /// Map a worker's modified legacy payload back onto the original message. + /// + /// Only the fields the legacy payload carries are written. A modification + /// the message has nowhere to hold (tool headers, resource metadata, a + /// prompt result description, a resource blob) fails closed: dropping it + /// would silently undo what the plugin asked for. + pub fn apply_modified(&self, modified: Value) -> Result { + let mut payload = self.original.clone(); + let part = payload + .message + .content + .get_mut(self.part) + .ok_or_else(|| self.error("the original content part is gone"))?; + + match (self.shim, part) { + (Shim::ToolPreInvoke, ContentPart::ToolCall { content }) => { + let legacy: ToolPreInvokePayload = self.parse(modified)?; + if legacy.headers.as_ref().is_some_and(|h| !h.is_empty()) { + return Err(self.error( + "the plugin returned `headers`, which a CMF tool call cannot carry", + )); + } + content.name = legacy.name; + content.arguments = legacy.args.unwrap_or_default(); + }, + (Shim::ToolPostInvoke, ContentPart::ToolResult { content }) => { + let legacy: ToolPostInvokePayload = self.parse(modified)?; + if content.is_error && !result_says_error(&legacy.result) { + return Err(self.error( + "the plugin cleared `isError` on an error tool result, and the CMF \ + `is_error` flag is not mapped back", + )); + } + // A plugin may turn a success into an error; the CMF flag + // follows, so downstream reads the error the plugin set. + content.is_error = content.is_error || result_says_error(&legacy.result); + content.tool_name = legacy.name; + content.content = legacy.result; + }, + (Shim::PromptPreFetch, ContentPart::PromptRequest { content }) => { + let legacy: PromptPreFetchPayload = self.parse(modified)?; + content.name = legacy.prompt_id; + content.arguments = legacy + .args + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, Value::String(v))) + .collect(); + }, + (Shim::PromptPostFetch, ContentPart::PromptResult { content }) => { + let legacy: PromptPostFetchPayload = self.parse(modified)?; + content.messages = self.messages_from_legacy(legacy.result, &content.messages)?; + content.prompt_name = legacy.prompt_id; + }, + (Shim::ResourcePreFetch, ContentPart::Resource { content }) => { + let uri = self.resource_pre_uri(modified)?; + // Embedded content, its type and its size describe the + // original URI. Carrying them onto a rewritten URI would serve + // the old content under the address the plugin chose. + if uri != content.uri + && (content.is_embedded() + || content.mime_type.is_some() + || content.size_bytes.is_some()) + { + return Err(self.error( + "the plugin rewrote the URI of a resource that carries content, \ + mime_type or size_bytes from the original URI", + )); + } + content.uri = uri; + }, + (Shim::ResourcePreFetch, ContentPart::ResourceRef { content }) => { + content.uri = self.resource_pre_uri(modified)?; + }, + (Shim::ResourcePostFetch, ContentPart::Resource { content }) => { + let legacy: ResourcePostFetchPayload = self.parse(modified)?; + let before = content.content.clone(); + let original_uri = std::mem::replace(&mut content.uri, legacy.uri); + match legacy.content { + Value::Null => content.content = None, + Value::String(text) => content.content = Some(text), + Value::Object(fields) => { + if fields.get("blob").is_some_and(|b| !b.is_null()) { + return Err(self.error( + "the plugin returned resource `content.blob`; binary resource \ + content is not carried by the shim", + )); + } + // `content.id` and `content.uri` have no slot of their + // own: the part keeps one id and one URI. A change to + // either would otherwise be dropped. + if fields.get("id").is_some_and(|id| { + id.as_str() != Some(content.resource_request_id.as_str()) + }) { + return Err(self.error( + "the plugin changed `content.id`, which is not mapped back", + )); + } + if fields.get("uri").is_some_and(|u| { + u.as_str() != Some(content.uri.as_str()) + && u.as_str() != Some(original_uri.as_str()) + }) { + return Err(self.error( + "the plugin changed `content.uri` to a value other than `uri`, \ + and only `uri` is mapped back", + )); + } + content.content = match fields.get("text") { + None | Some(Value::Null) => None, + Some(Value::String(text)) => Some(text.clone()), + Some(_) => { + return Err(self.error("`content.text` must be a string or null")); + }, + }; + if let Some(mime) = fields.get("mime_type") { + content.mime_type = match mime { + Value::Null => None, + Value::String(m) => Some(m.clone()), + _ => { + return Err( + self.error("`content.mime_type` must be a string or null") + ); + }, + }; + } + }, + _ => return Err(self.error("`content` must be an object, a string, or null")), + } + // A size measured from the original text would misstate the + // text the plugin returned. + if content.content != before && content.size_bytes.is_some() { + content.size_bytes = content.content.as_ref().map(|t| t.len() as u64); + } + }, + _ => return Err(self.error("the original content part changed kind")), + } + + Ok(payload) + } + + fn resource_pre_uri(&self, modified: Value) -> Result { + let legacy: ResourcePreFetchPayload = self.parse(modified)?; + if legacy.metadata.as_ref().is_some_and(|m| !m.is_empty()) { + return Err(self.error( + "the plugin returned resource `metadata`, which a CMF resource part cannot carry", + )); + } + Ok(legacy.uri) + } + + /// Rebuild CMF prompt messages from a legacy `PromptResult` shape. + /// + /// A message keeps its original `schema_version` and `channel` when one + /// exists at the same position; a message the plugin appended gets the + /// defaults. + fn messages_from_legacy( + &self, + result: Value, + original: &[Message], + ) -> Result, HostError> { + let Value::Object(result) = result else { + return Err(self.error("`result` must be an object with a `messages` list")); + }; + if result.get("description").is_some_and(|d| !d.is_null()) { + return Err(self.error( + "the plugin returned a prompt result `description`, which a CMF prompt result \ + cannot carry", + )); + } + let Some(Value::Array(items)) = result.get("messages") else { + return Err(self.error("`result.messages` must be a list")); + }; + + items + .iter() + .enumerate() + .map(|(i, item)| { + let role = item + .get("role") + .cloned() + .and_then(|r| serde_json::from_value::(r).ok()) + .ok_or_else(|| { + self.error(&format!("`result.messages[{i}].role` is not a CMF role")) + })?; + let text = match item.get("content") { + Some(Value::Object(c)) + if c.get("type").and_then(Value::as_str) == Some("text") => + { + c.get("text").and_then(Value::as_str) + }, + _ => None, + } + .ok_or_else(|| { + self.error(&format!( + "`result.messages[{i}].content` must be text content \ + ({{\"type\": \"text\", \"text\": ...}})" + )) + })?; + + let mut message = original + .get(i) + .cloned() + .unwrap_or_else(|| Message::text(role, "")); + message.role = role; + message.content = vec![ContentPart::Text { + text: text.to_string(), + }]; + Ok(message) + }) + .collect() + } + + fn parse(&self, modified: Value) -> Result { + serde_json::from_value(modified).map_err(|e| HostError::Protocol { + message: format!( + "worker returned a modified payload that does not match the legacy hook '{}' \ + (dispatched as '{}'): {e}", + self.shim.legacy_hook(), + self.hook_name + ), + }) + } + + fn error(&self, message: &str) -> HostError { + shim_error(&self.hook_name, self.shim.legacy_hook(), message) + } +} + +fn shim_error(hook_name: &str, legacy: &str, message: &str) -> HostError { + HostError::LegacyShim { + hook: hook_name.to_string(), + message: format!("translating to the legacy '{legacy}' payload: {message}"), + } +} + +/// Translate a `cmf.*` hook invocation into the legacy hook a worker without +/// `cmf_hooks` support understands. +/// +/// Fails closed for the LLM hooks (no legacy equivalent), for a payload that +/// is not a `MessagePayload`, and for a message that does not hold exactly one +/// content part of the kind the hook's legacy payload is built from. +pub fn to_legacy( + hook_name: &str, + payload: &dyn PluginPayload, +) -> Result { + let Some(shim) = Shim::for_hook(hook_name) else { + return Err(HostError::LegacyShim { + hook: hook_name.to_string(), + message: "it has no legacy hook equivalent, so only a worker that claims the \ + `cmf_hooks` feature can run it. Upgrade the cpex package in the plugin's \ + venv, or remove the hook from the plugin's `hooks:` list." + .into(), + }); + }; + let legacy = shim.legacy_hook(); + + let original = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| shim_error(hook_name, legacy, "the payload is not a CMF MessagePayload"))? + .clone(); + + let candidates: Vec = original + .message + .content + .iter() + .enumerate() + .filter(|(_, p)| shim.matches(p)) + .map(|(i, _)| i) + .collect(); + let part = match candidates.as_slice() { + [only] => *only, + [] => { + return Err(shim_error( + hook_name, + legacy, + &format!("the message has no {} content part", shim.part_name()), + )); + }, + many => { + return Err(shim_error( + hook_name, + legacy, + &format!( + "the message has {} {} content parts and the legacy payload holds one", + many.len(), + shim.part_name() + ), + )); + }, + }; + + // The legacy payload holds the one part. On a post hook anything else in + // the message would go downstream on allow without the plugin having seen + // it. On a pre hook the plugin authorizes the part it sees, and siblings + // are returned unchanged. + if let Some(sibling) = original + .message + .content + .iter() + .enumerate() + .find(|(i, p)| shim.is_post() && *i != part && carries_content(p)) + .map(|(_, p)| p) + { + return Err(shim_error( + hook_name, + legacy, + &format!( + "the message holds a {} content part beside the {} part, which the legacy \ + payload cannot carry, so a plugin could not inspect or redact it", + part_kind(sibling), + shim.part_name() + ), + )); + } + + let err = |message: &str| shim_error(hook_name, legacy, message); + let json = match (shim, &original.message.content[part]) { + (Shim::ToolPreInvoke, ContentPart::ToolCall { content }) => { + if is_set(&content.namespace) { + return Err(err( + "the tool call has a `namespace`, which the legacy payload cannot carry; \ + a plugin that authorizes by bare `name` would approve it in any namespace", + )); + } + to_json( + &ToolPreInvokePayload { + name: content.name.clone(), + args: Some(content.arguments.clone()), + headers: None, + }, + &err, + )? + }, + (Shim::ToolPostInvoke, ContentPart::ToolResult { content }) => { + if content.is_error && !result_says_error(&content.content) { + return Err(err( + "the tool result has `is_error` set, and its `content` does not carry \ + `\"isError\": true` for the legacy plugin to see", + )); + } + to_json( + &ToolPostInvokePayload { + name: content.tool_name.clone(), + result: content.content.clone(), + }, + &err, + )? + }, + (Shim::PromptPreFetch, ContentPart::PromptRequest { content }) => { + if is_set(&content.server_id) { + return Err(err( + "the prompt request has a `server_id`, which the legacy payload cannot \ + carry; a plugin that authorizes by bare `prompt_id` would approve it on \ + any server", + )); + } + // dict[str, str] on the Python side, which Pydantic will not coerce + // a number or an object into. + let mut args = HashMap::with_capacity(content.arguments.len()); + for (key, value) in &content.arguments { + let Value::String(s) = value else { + return Err(err(&format!( + "prompt argument '{key}' is not a string, and the legacy payload's \ + `args` is dict[str, str]" + ))); + }; + args.insert(key.clone(), s.clone()); + } + to_json( + &PromptPreFetchPayload { + prompt_id: content.name.clone(), + args: Some(args), + }, + &err, + )? + }, + (Shim::PromptPostFetch, ContentPart::PromptResult { content }) => { + // The legacy result holds only messages. Text left in these fields + // would survive a plugin that redacted the messages. + if is_set(&content.content) { + return Err(err( + "the prompt result has rendered `content`, which the legacy payload cannot \ + carry, so a plugin could not inspect or redact it", + )); + } + if is_set(&content.error_message) { + return Err(err( + "the prompt result has an `error_message`, which the legacy payload cannot \ + carry, so a plugin could not inspect or redact it", + )); + } + if content.is_error { + return Err(err( + "the prompt result has `is_error` set, which the legacy payload cannot carry", + )); + } + let mut messages = Vec::with_capacity(content.messages.len()); + for (i, message) in content.messages.iter().enumerate() { + let [ContentPart::Text { text }] = message.content.as_slice() else { + return Err(err(&format!( + "prompt result message {i} is not a single text part, and the legacy \ + prompt message holds one text content" + ))); + }; + messages.push(serde_json::json!({ + "role": message.role, + "content": { "type": "text", "text": text }, + })); + } + to_json( + &PromptPostFetchPayload { + prompt_id: content.prompt_name.clone(), + result: serde_json::json!({ "messages": messages, "description": null }), + }, + &err, + )? + }, + (Shim::ResourcePreFetch, ContentPart::Resource { content }) => { + refuse_resource_version(content, &err)?; + to_json( + &ResourcePreFetchPayload { + uri: content.uri.clone(), + metadata: None, + }, + &err, + )? + }, + (Shim::ResourcePreFetch, ContentPart::ResourceRef { content }) => to_json( + &ResourcePreFetchPayload { + uri: content.uri.clone(), + metadata: None, + }, + &err, + )?, + (Shim::ResourcePostFetch, ContentPart::Resource { content }) => { + refuse_resource_version(content, &err)?; + if content.blob.is_some() { + return Err(err( + "the resource carries binary `blob` content, which the shim does not \ + translate", + )); + } + // The legacy `ResourceContent` shape 0.1.x plugins read. + to_json( + &ResourcePostFetchPayload { + uri: content.uri.clone(), + content: serde_json::json!({ + "type": "resource", + "id": content.resource_request_id, + "uri": content.uri, + "mime_type": content.mime_type, + "text": content.content, + }), + }, + &err, + )? + }, + (Shim::ResourcePostFetch, ContentPart::ResourceRef { .. }) => { + return Err(err( + "the message holds a resource_ref with no content, and the legacy post-fetch \ + payload carries the content", + )); + }, + // `candidates` only holds parts `shim.matches` accepted, and every + // accepted pairing is handled above. + _ => return Err(err("unexpected content part")), + }; + + Ok(ShimmedPayload { + shim, + hook_name: hook_name.to_string(), + original, + part, + json, + }) +} + +/// Whether an optional string field holds a value. An empty string carries +/// nothing and counts as unset. +fn is_set(field: &Option) -> bool { + field.as_deref().is_some_and(|s| !s.is_empty()) +} + +/// Whether a part the legacy payload does not carry holds anything a plugin +/// would need to see. Only a text or thinking part with no text holds nothing. +fn carries_content(part: &ContentPart) -> bool { + match part { + ContentPart::Text { text } | ContentPart::Thinking { text } => !text.trim().is_empty(), + _ => true, + } +} + +/// A content part's `content_type`, for diagnostics. +fn part_kind(part: &ContentPart) -> &'static str { + match part { + ContentPart::Text { .. } => "text", + ContentPart::Thinking { .. } => "thinking", + ContentPart::ToolCall { .. } => "tool_call", + ContentPart::ToolResult { .. } => "tool_result", + ContentPart::Resource { .. } => "resource", + ContentPart::ResourceRef { .. } => "resource_ref", + ContentPart::PromptRequest { .. } => "prompt_request", + ContentPart::PromptResult { .. } => "prompt_result", + ContentPart::Image { .. } => "image", + ContentPart::Video { .. } => "video", + ContentPart::Audio { .. } => "audio", + ContentPart::Document { .. } => "document", + } +} + +/// Whether a legacy tool result tells the plugin it is an error, the MCP +/// `CallToolResult` way. +fn result_says_error(result: &Value) -> bool { + result.get("isError") == Some(&Value::Bool(true)) +} + +fn refuse_resource_version( + resource: &Resource, + err: &dyn Fn(&str) -> HostError, +) -> Result<(), HostError> { + if is_set(&resource.version) { + return Err(err( + "the resource has a `version`, which the legacy payload cannot carry; a plugin \ + that authorizes by `uri` would approve any revision", + )); + } + Ok(()) +} + +fn to_json(value: &T, err: &dyn Fn(&str) -> HostError) -> Result { + serde_json::to_value(value).map_err(|e| err(&format!("could not serialize: {e}"))) +} + +#[cfg(test)] +mod tests { + use praxis_policy_core::cmf::{ + Channel, PromptRequest, PromptResult, Resource, ResourceReference, ResourceType, ToolCall, + ToolResult, + }; + use serde_json::json; + + use super::*; + + // --- fixtures ----------------------------------------------------------- + + /// A text part that carries text. + fn context_text() -> ContentPart { + ContentPart::Text { + text: "unrelated context".into(), + } + } + + /// A message with the target part between two empty parts: the only + /// siblings the shim accepts, so every round trip can assert they survive. + fn message_around(part: ContentPart) -> MessagePayload { + let mut message = Message::with_content( + Role::Assistant, + vec![ + ContentPart::Text { + text: String::new(), + }, + part, + ContentPart::Thinking { text: " ".into() }, + ], + ); + message.channel = Some(Channel::Commentary); + MessagePayload { message } + } + + fn tool_call(args: Value) -> ContentPart { + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-1".into(), + name: "search".into(), + arguments: serde_json::from_value(args).unwrap(), + namespace: None, + }, + } + } + + fn tool_result(content: Value) -> ContentPart { + ContentPart::ToolResult { + content: ToolResult { + tool_call_id: "call-1".into(), + tool_name: "search".into(), + content, + is_error: false, + }, + } + } + + fn prompt_request(args: Value) -> ContentPart { + ContentPart::PromptRequest { + content: PromptRequest { + prompt_request_id: "pr-1".into(), + name: "greeting".into(), + arguments: serde_json::from_value(args).unwrap(), + server_id: None, + }, + } + } + + fn prompt_result(messages: Vec) -> ContentPart { + ContentPart::PromptResult { + content: PromptResult { + prompt_request_id: "pr-1".into(), + prompt_name: "greeting".into(), + messages, + content: None, + is_error: false, + error_message: None, + }, + } + } + + fn resource(text: Option<&str>) -> Resource { + Resource { + resource_request_id: "res-1".into(), + uri: "file:///tmp/a.txt".into(), + name: Some("a".into()), + description: None, + resource_type: ResourceType::File, + content: text.map(str::to_string), + blob: None, + mime_type: Some("text/plain".into()), + size_bytes: Some(5), + annotations: HashMap::from([("k".into(), json!("v"))]), + version: None, + } + } + + fn resource_ref() -> ContentPart { + ContentPart::ResourceRef { + content: ResourceReference { + resource_request_id: "res-1".into(), + uri: "file:///tmp/a.txt".into(), + name: None, + resource_type: ResourceType::File, + range_start: Some(1), + range_end: None, + selector: None, + }, + } + } + + fn json_of(payload: &MessagePayload) -> Value { + serde_json::to_value(payload).unwrap() + } + + /// Assert the parts around index 1 and the message-level fields are what + /// `message_around` built. + fn assert_surroundings_kept(payload: &MessagePayload) { + let json = json_of(payload); + assert_eq!( + json["message"]["content"][0], + json!({"content_type": "text", "text": ""}) + ); + assert_eq!( + json["message"]["content"][2], + json!({"content_type": "thinking", "text": " "}) + ); + assert_eq!(json["message"]["role"], "assistant"); + assert_eq!(json["message"]["channel"], "commentary"); + assert_eq!(payload.message.content.len(), 3); + } + + /// The JSON the target part serializes to, for a whole-part comparison. + fn part_json(payload: &MessagePayload) -> Value { + json_of(payload)["message"]["content"][1].clone() + } + + fn assert_shim_error(err: HostError, hook: &str, needle: &str) { + let text = err.to_string(); + assert!(matches!(err, HostError::LegacyShim { .. }), "got {err:?}"); + assert!(text.contains(hook), "the error must name the hook: {text}"); + assert!(text.contains(needle), "expected '{needle}' in: {text}"); + } + + // --- hook table --------------------------------------------------------- + + #[test] + fn the_six_tool_prompt_and_resource_hooks_map_to_legacy_names() { + for (cmf, legacy) in [ + ("cmf.tool_pre_invoke", "tool_pre_invoke"), + ("cmf.tool_post_invoke", "tool_post_invoke"), + ("cmf.prompt_pre_invoke", "prompt_pre_fetch"), + ("cmf.prompt_post_invoke", "prompt_post_fetch"), + ("cmf.resource_pre_fetch", "resource_pre_fetch"), + ("cmf.resource_post_fetch", "resource_post_fetch"), + ] { + assert_eq!(legacy_hook_name(cmf), Some(legacy), "{cmf}"); + assert!(is_cmf_hook(cmf)); + } + } + + #[test] + fn every_cmf_hook_ppe_dispatches_is_covered() { + // A new upstream cmf hook must be either shimmed or refused, never + // forwarded under a name the old worker does not know. + for hook in praxis_policy_core::hooks::builtin_hook_types() { + let name = hook.as_str(); + if name.starts_with("cmf.") { + assert!( + is_cmf_hook(name), + "'{name}' is not covered by the shim table" + ); + } + } + } + + #[test] + fn non_cmf_hooks_are_not_shimmed() { + for hook in [ + "tool_pre_invoke", + "identity.resolve", + "token.delegate", + "cmf.custom", + ] { + assert!(!is_cmf_hook(hook), "{hook}"); + assert_eq!(legacy_hook_name(hook), None, "{hook}"); + } + } + + // --- cmf.tool_pre_invoke ------------------------------------------------ + + #[test] + fn tool_pre_invoke_sends_name_and_args() { + let payload = message_around(tool_call(json!({"q": "rust", "n": 3}))); + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + + assert_eq!(shimmed.legacy_hook(), "tool_pre_invoke"); + assert_eq!( + shimmed.payload(), + &json!({"name": "search", "args": {"q": "rust", "n": 3}}) + ); + } + + #[test] + fn tool_pre_invoke_unchanged_round_trip_preserves_the_message() { + let payload = message_around(tool_call(json!({"q": "rust"}))); + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + + // What the 0.1.x worker's `model_dump(mode="json")` returns for an + // unmodified payload: every field, `headers` as null. + let back = shimmed + .apply_modified(json!({"name": "search", "args": {"q": "rust"}, "headers": null})) + .unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn tool_pre_invoke_modified_args_map_back() { + let payload = message_around(tool_call(json!({"q": "alice@example.com"}))); + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + + let back = shimmed + .apply_modified( + json!({"name": "search", "args": {"q": "[REDACTED]", "extra": 1}, "headers": {}}), + ) + .unwrap(); + assert_eq!( + part_json(&back), + json!({"content_type": "tool_call", "content": { + "tool_call_id": "call-1", + "name": "search", + "arguments": {"q": "[REDACTED]", "extra": 1}, + }}) + ); + assert_surroundings_kept(&back); + } + + #[test] + fn tool_pre_invoke_returned_headers_fail_closed() { + let payload = message_around(tool_call(json!({}))); + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"name": "search", "args": {}, "headers": {"X-Injected": "1"}})) + .unwrap_err(); + assert_shim_error(err, "cmf.tool_pre_invoke", "headers"); + } + + #[test] + fn tool_pre_invoke_without_a_tool_call_fails_closed() { + let payload = MessagePayload { + message: Message::text(Role::User, "what is the weather?"), + }; + let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.tool_pre_invoke", "no tool_call content part"); + } + + #[test] + fn tool_pre_invoke_with_two_tool_calls_fails_closed() { + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![tool_call(json!({})), tool_call(json!({}))], + ), + }; + let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.tool_pre_invoke", "2 tool_call content parts"); + } + + #[test] + fn a_modified_payload_missing_required_fields_is_a_protocol_error() { + let payload = message_around(tool_call(json!({}))); + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + let err = shimmed.apply_modified(json!({"args": {}})).unwrap_err(); + assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}"); + assert!(err.to_string().contains("tool_pre_invoke"), "{err}"); + } + + // --- cmf.tool_post_invoke ----------------------------------------------- + + #[test] + fn tool_post_invoke_sends_name_and_result() { + let result = json!({"content": [{"type": "text", "text": "3 hits"}], "isError": false}); + let payload = message_around(tool_result(result.clone())); + let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap(); + + assert_eq!(shimmed.legacy_hook(), "tool_post_invoke"); + assert_eq!( + shimmed.payload(), + &json!({"name": "search", "result": result}) + ); + + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn tool_post_invoke_modified_result_maps_back() { + let payload = message_around(tool_result(json!({"text": "ssn 123-45-6789"}))); + let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap(); + + let back = shimmed + .apply_modified(json!({"name": "search", "result": {"text": "ssn [REDACTED]"}})) + .unwrap(); + assert_eq!( + part_json(&back), + json!({"content_type": "tool_result", "content": { + "tool_call_id": "call-1", + "tool_name": "search", + "content": {"text": "ssn [REDACTED]"}, + "is_error": false, + }}) + ); + assert_surroundings_kept(&back); + } + + #[test] + fn tool_post_invoke_without_a_tool_result_fails_closed() { + let payload = message_around(tool_call(json!({}))); + let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.tool_post_invoke", "no tool_result content part"); + } + + // --- cmf.prompt_pre_invoke ---------------------------------------------- + + #[test] + fn prompt_pre_invoke_sends_prompt_id_and_string_args() { + let payload = message_around(prompt_request(json!({"lang": "en"}))); + let shimmed = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap(); + + assert_eq!(shimmed.legacy_hook(), "prompt_pre_fetch"); + assert_eq!( + shimmed.payload(), + &json!({"prompt_id": "greeting", "args": {"lang": "en"}}) + ); + + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn prompt_pre_invoke_modified_args_map_back() { + let payload = message_around(prompt_request(json!({"lang": "en", "who": "bob"}))); + let shimmed = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap(); + + let back = shimmed + .apply_modified(json!({"prompt_id": "greeting", "args": {"lang": "fr"}})) + .unwrap(); + assert_eq!( + part_json(&back), + json!({"content_type": "prompt_request", "content": { + "prompt_request_id": "pr-1", + "name": "greeting", + "arguments": {"lang": "fr"}, + }}) + ); + assert_surroundings_kept(&back); + } + + #[test] + fn prompt_pre_invoke_with_a_non_string_argument_fails_closed() { + let payload = message_around(prompt_request(json!({"count": 5}))); + let err = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.prompt_pre_invoke", "'count' is not a string"); + } + + #[test] + fn prompt_pre_invoke_without_a_prompt_request_fails_closed() { + let payload = message_around(tool_call(json!({}))); + let err = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap_err(); + assert_shim_error( + err, + "cmf.prompt_pre_invoke", + "no prompt_request content part", + ); + } + + // --- cmf.prompt_post_invoke --------------------------------------------- + + #[test] + fn prompt_post_invoke_sends_mcp_shaped_messages() { + let payload = message_around(prompt_result(vec![ + Message::text(Role::System, "be brief"), + Message::text(Role::User, "hi bob"), + ])); + let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap(); + + assert_eq!(shimmed.legacy_hook(), "prompt_post_fetch"); + assert_eq!( + shimmed.payload(), + &json!({"prompt_id": "greeting", "result": { + "messages": [ + {"role": "system", "content": {"type": "text", "text": "be brief"}}, + {"role": "user", "content": {"type": "text", "text": "hi bob"}}, + ], + "description": null, + }}) + ); + + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn prompt_post_invoke_modified_messages_map_back() { + let mut first = Message::text(Role::User, "hi bob"); + first.channel = Some(Channel::Final); + let payload = message_around(prompt_result(vec![first])); + let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap(); + + // Pydantic dumps the fixture TextContent's extra fields; they are + // tolerated and dropped. + let back = shimmed + .apply_modified(json!({"prompt_id": "greeting", "result": { + "messages": [ + {"role": "user", "content": {"type": "text", "text": "hi [NAME]", "annotations": null}}, + {"role": "assistant", "content": {"type": "text", "text": "appended"}}, + ], + "description": null, + }})) + .unwrap(); + + let part = part_json(&back); + assert_eq!( + part["content"]["prompt_request_id"], "pr-1", + "unmapped fields survive" + ); + let messages = &part["content"]["messages"]; + assert_eq!(messages[0]["content"][0]["text"], "hi [NAME]"); + assert_eq!( + messages[0]["channel"], "final", + "the original message's channel is kept" + ); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"][0]["text"], "appended"); + assert_surroundings_kept(&back); + } + + #[test] + fn prompt_post_invoke_with_a_multipart_message_fails_closed() { + let payload = message_around(prompt_result(vec![Message::with_content( + Role::User, + vec![context_text(), context_text()], + )])); + let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.prompt_post_invoke", "not a single text part"); + } + + #[test] + fn prompt_post_invoke_returned_description_fails_closed() { + let payload = message_around(prompt_result(vec![])); + let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap(); + let err = shimmed + .apply_modified( + json!({"prompt_id": "greeting", "result": {"messages": [], "description": "x"}}), + ) + .unwrap_err(); + assert_shim_error(err, "cmf.prompt_post_invoke", "description"); + } + + #[test] + fn prompt_post_invoke_returned_non_text_content_fails_closed() { + let payload = message_around(prompt_result(vec![])); + let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"prompt_id": "greeting", "result": {"messages": [ + {"role": "user", "content": {"type": "image", "data": "..."}} + ]}})) + .unwrap_err(); + assert_shim_error(err, "cmf.prompt_post_invoke", "messages[0].content"); + } + + // --- cmf.resource_pre_fetch --------------------------------------------- + + #[test] + fn resource_pre_fetch_sends_the_uri_from_a_reference() { + let payload = message_around(resource_ref()); + let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap(); + + assert_eq!(shimmed.legacy_hook(), "resource_pre_fetch"); + assert_eq!(shimmed.payload(), &json!({"uri": "file:///tmp/a.txt"})); + + // `metadata: {}` is what Pydantic's default dumps as. + let back = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {}})) + .unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + /// A resource with no fetched content yet: nothing describes the URI + /// beyond labels. + fn unfetched_resource() -> Resource { + Resource { + mime_type: None, + size_bytes: None, + ..resource(None) + } + } + + #[test] + fn resource_pre_fetch_modified_uri_maps_back_onto_an_unfetched_resource() { + let payload = message_around(ContentPart::Resource { + content: unfetched_resource(), + }); + let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap(); + + let back = shimmed + .apply_modified(json!({"uri": "file:///safe/a.txt", "metadata": {}})) + .unwrap(); + let mut expected = unfetched_resource(); + expected.uri = "file:///safe/a.txt".into(); + assert_eq!( + part_json(&back), + serde_json::to_value(ContentPart::Resource { content: expected }).unwrap() + ); + assert_surroundings_kept(&back); + } + + #[test] + fn resource_pre_fetch_returned_metadata_fails_closed() { + let payload = message_around(resource_ref()); + let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {"etag": "x"}})) + .unwrap_err(); + assert_shim_error(err, "cmf.resource_pre_fetch", "metadata"); + } + + #[test] + fn resource_pre_fetch_without_a_resource_fails_closed() { + let payload = MessagePayload { + message: Message::text(Role::User, "no resource here"), + }; + let err = to_legacy("cmf.resource_pre_fetch", &payload).unwrap_err(); + assert_shim_error(err, "cmf.resource_pre_fetch", "no resource or resource_ref"); + } + + // --- cmf.resource_post_fetch -------------------------------------------- + + #[test] + fn resource_post_fetch_sends_legacy_resource_content() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("hello")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + + assert_eq!(shimmed.legacy_hook(), "resource_post_fetch"); + assert_eq!( + shimmed.payload(), + &json!({"uri": "file:///tmp/a.txt", "content": { + "type": "resource", + "id": "res-1", + "uri": "file:///tmp/a.txt", + "mime_type": "text/plain", + "text": "hello", + }}) + ); + + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn resource_post_fetch_modified_content_maps_back() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("password=hunter2")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + + let back = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": { + "type": "resource", "id": "res-1", "uri": "file:///tmp/a.txt", + "mime_type": "text/plain", "text": "password=[REDACTED]", "blob": null, + }})) + .unwrap(); + let mut expected = resource(Some("password=[REDACTED]")); + expected.size_bytes = Some("password=[REDACTED]".len() as u64); + assert_eq!( + part_json(&back), + serde_json::to_value(ContentPart::Resource { content: expected }).unwrap(), + "the text changes and the size follows it; name and annotations survive" + ); + assert_surroundings_kept(&back); + } + + #[test] + fn resource_post_fetch_accepts_plain_string_content() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("a")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + let back = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": "b"})) + .unwrap(); + assert_eq!(part_json(&back)["content"]["content"], "b"); + } + + #[test] + fn resource_post_fetch_with_a_blob_fails_closed() { + let mut binary = resource(None); + binary.blob = Some(vec![0, 1, 2]); + let payload = message_around(ContentPart::Resource { content: binary }); + let err = to_legacy("cmf.resource_post_fetch", &payload).unwrap_err(); + assert_shim_error(err, "cmf.resource_post_fetch", "blob"); + } + + #[test] + fn resource_post_fetch_with_only_a_reference_fails_closed() { + let payload = message_around(resource_ref()); + let err = to_legacy("cmf.resource_post_fetch", &payload).unwrap_err(); + assert_shim_error( + err, + "cmf.resource_post_fetch", + "resource_ref with no content", + ); + } + + #[test] + fn resource_post_fetch_returned_blob_fails_closed() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("a")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {"blob": "AAEC"}})) + .unwrap_err(); + assert_shim_error(err, "cmf.resource_post_fetch", "blob"); + } + + // --- fields the legacy payload cannot carry ------------------------------ + + #[test] + fn tool_pre_invoke_with_a_namespace_fails_closed() { + let mut part = tool_call(json!({})); + if let ContentPart::ToolCall { content } = &mut part { + content.namespace = Some("other-tenant".into()); + } + let err = to_legacy("cmf.tool_pre_invoke", &message_around(part)).unwrap_err(); + assert_shim_error(err, "cmf.tool_pre_invoke", "namespace"); + } + + #[test] + fn tool_pre_invoke_with_an_empty_namespace_is_unqualified() { + let mut part = tool_call(json!({})); + if let ContentPart::ToolCall { content } = &mut part { + content.namespace = Some(String::new()); + } + let payload = message_around(part); + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + fn error_tool_result(content: Value) -> ContentPart { + let mut part = tool_result(content); + if let ContentPart::ToolResult { content } = &mut part { + content.is_error = true; + } + part + } + + #[test] + fn tool_post_invoke_error_the_result_does_not_show_fails_closed() { + let payload = message_around(error_tool_result(json!({"text": "boom"}))); + let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.tool_post_invoke", "is_error"); + } + + #[test] + fn tool_post_invoke_error_the_result_shows_round_trips() { + let payload = message_around(error_tool_result(json!({"content": [], "isError": true}))); + let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap(); + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn tool_post_invoke_clearing_is_error_fails_closed() { + let payload = message_around(error_tool_result(json!({"content": [], "isError": true}))); + let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"name": "search", "result": {"content": [], "isError": false}})) + .unwrap_err(); + assert_shim_error(err, "cmf.tool_post_invoke", "isError"); + } + + #[test] + fn prompt_pre_invoke_with_a_server_id_fails_closed() { + let mut part = prompt_request(json!({})); + if let ContentPart::PromptRequest { content } = &mut part { + content.server_id = Some("other-server".into()); + } + let err = to_legacy("cmf.prompt_pre_invoke", &message_around(part)).unwrap_err(); + assert_shim_error(err, "cmf.prompt_pre_invoke", "server_id"); + } + + fn prompt_result_with(edit: impl FnOnce(&mut PromptResult)) -> MessagePayload { + let mut part = prompt_result(vec![Message::text(Role::User, "hi bob")]); + if let ContentPart::PromptResult { content } = &mut part { + edit(content); + } + message_around(part) + } + + #[test] + fn prompt_post_invoke_with_rendered_content_fails_closed() { + // A plugin that redacts `messages` would leave "hi bob" here. + let payload = prompt_result_with(|r| r.content = Some("hi bob".into())); + let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.prompt_post_invoke", "`content`"); + } + + #[test] + fn prompt_post_invoke_with_an_error_message_fails_closed() { + let payload = prompt_result_with(|r| r.error_message = Some("failed for bob".into())); + let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.prompt_post_invoke", "error_message"); + } + + #[test] + fn prompt_post_invoke_with_is_error_fails_closed() { + let payload = prompt_result_with(|r| r.is_error = true); + let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.prompt_post_invoke", "is_error"); + } + + #[test] + fn resource_hooks_with_a_version_fail_closed() { + let mut versioned = resource(Some("a")); + versioned.version = Some("v1".into()); + let payload = message_around(ContentPart::Resource { content: versioned }); + for hook in ["cmf.resource_pre_fetch", "cmf.resource_post_fetch"] { + let err = to_legacy(hook, &payload).unwrap_err(); + assert_shim_error(err, hook, "version"); + } + } + + #[test] + fn resource_pre_fetch_unchanged_uri_keeps_embedded_content() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("hello")), + }); + let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap(); + let back = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {}})) + .unwrap(); + assert_eq!(json_of(&back), json_of(&payload)); + } + + #[test] + fn resource_pre_fetch_uri_rewrite_on_fetched_content_fails_closed() { + let embedded = resource(Some("hello")); + let mut sized = unfetched_resource(); + sized.size_bytes = Some(5); + let mut typed = unfetched_resource(); + typed.mime_type = Some("text/plain".into()); + let mut binary = unfetched_resource(); + binary.blob = Some(vec![1]); + for res in [embedded, sized, typed, binary] { + let payload = message_around(ContentPart::Resource { content: res }); + let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"uri": "file:///safe/a.txt", "metadata": {}})) + .unwrap_err(); + assert_shim_error(err, "cmf.resource_pre_fetch", "rewrote the URI"); + } + } + + #[test] + fn resource_post_fetch_changed_content_id_fails_closed() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("a")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": { + "id": "res-2", "uri": "file:///tmp/a.txt", "text": "a", + }})) + .unwrap_err(); + assert_shim_error(err, "cmf.resource_post_fetch", "content.id"); + } + + #[test] + fn resource_post_fetch_content_uri_alone_changed_fails_closed() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("a")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + let err = shimmed + .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": { + "id": "res-1", "uri": "file:///safe/a.txt", "text": "a", + }})) + .unwrap_err(); + assert_shim_error(err, "cmf.resource_post_fetch", "content.uri"); + } + + #[test] + fn resource_post_fetch_top_level_uri_change_maps_back() { + let payload = message_around(ContentPart::Resource { + content: resource(Some("a")), + }); + let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap(); + let back = shimmed + .apply_modified(json!({"uri": "file:///safe/a.txt", "content": { + "id": "res-1", "uri": "file:///tmp/a.txt", "text": "a", + }})) + .unwrap(); + assert_eq!(part_json(&back)["content"]["uri"], "file:///safe/a.txt"); + assert_eq!( + part_json(&back)["content"]["size_bytes"], + 5, + "text unchanged" + ); + } + + #[test] + fn tool_post_invoke_a_plugin_setting_is_error_sets_the_flag() { + let payload = message_around(tool_result(json!({"content": [], "isError": false}))); + let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap(); + let back = shimmed + .apply_modified(json!({"name": "search", "result": {"content": [], "isError": true}})) + .unwrap(); + assert_eq!(part_json(&back)["content"]["is_error"], true); + assert_eq!(part_json(&back)["content"]["content"]["isError"], true); + } + + // --- sibling content parts --------------------------------------------- + + /// Every shimmed hook, with a message holding just its part. + fn each_hook_with_its_part() -> Vec<(&'static str, ContentPart)> { + vec![ + ("cmf.tool_pre_invoke", tool_call(json!({}))), + ("cmf.tool_post_invoke", tool_result(json!({"text": "ok"}))), + ("cmf.prompt_pre_invoke", prompt_request(json!({}))), + ("cmf.prompt_post_invoke", prompt_result(vec![])), + ("cmf.resource_pre_fetch", resource_ref()), + ( + "cmf.resource_post_fetch", + ContentPart::Resource { + content: resource(Some("a")), + }, + ), + ] + } + + /// Whether a shimmed hook is a post hook. + fn is_post_hook(hook: &str) -> bool { + hook.contains("_post_") + } + + /// Sibling parts that carry text. + fn texty_siblings() -> [ContentPart; 2] { + [ + ContentPart::Text { + text: "SSN 123-45-6789".into(), + }, + ContentPart::Thinking { + text: "SSN 123-45-6789".into(), + }, + ] + } + + #[test] + fn a_sibling_text_part_fails_closed_on_post_hooks() { + // The plugin would approve the one result part it sees while the text + // beside it went downstream unredacted. + for (hook, part) in each_hook_with_its_part() + .into_iter() + .filter(|(h, _)| is_post_hook(h)) + { + for sibling in texty_siblings() { + let payload = MessagePayload { + message: Message::with_content(Role::Assistant, vec![sibling, part.clone()]), + }; + let err = to_legacy(hook, &payload).unwrap_err(); + assert_shim_error(err, hook, "beside the"); + } + } + } + + #[test] + fn a_sibling_text_part_passes_through_on_pre_hooks() { + // The plugin authorizes the part it sees. Narration beside it is + // returned unchanged, through both an allow and a modification. + for (hook, part) in each_hook_with_its_part() + .into_iter() + .filter(|(h, _)| !is_post_hook(h)) + { + for sibling in texty_siblings() { + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![sibling.clone(), part.clone()], + ), + }; + let shimmed = to_legacy(hook, &payload).unwrap(); + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_eq!(back.message.content.len(), 2, "{hook}"); + assert_eq!( + serde_json::to_value(&back.message.content[0]).unwrap(), + serde_json::to_value(&sibling).unwrap(), + "{hook}" + ); + assert_eq!( + json_of(&back), + json_of(&payload), + "{hook}: an unmodified round trip changes nothing" + ); + } + } + } + + #[test] + fn a_pre_hook_rewrite_keeps_the_narration_sibling() { + let narration = ContentPart::Text { + text: "Let me look that up.".into(), + }; + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![narration.clone(), tool_call(json!({"q": "a"}))], + ), + }; + let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap(); + let back = shimmed + .apply_modified(json!({"name": "search", "args": {"q": "b"}})) + .unwrap(); + let json = json_of(&back); + assert_eq!( + json["message"]["content"][0], + serde_json::to_value(&narration).unwrap() + ); + assert_eq!( + json["message"]["content"][1]["content"]["arguments"]["q"], + "b" + ); + } + + #[test] + fn a_sibling_part_of_another_kind_fails_closed() { + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ + tool_result(json!({"text": "ok"})), + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-2".into(), + name: "other".into(), + arguments: HashMap::new(), + namespace: None, + }, + }, + ], + ), + }; + let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.tool_post_invoke", "tool_call content part beside"); + } + + #[test] + fn a_sibling_refusal_names_no_content() { + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ + ContentPart::Text { + text: "SECRET-SIBLING-TEXT".into(), + }, + tool_result(json!({"text": "ok"})), + ], + ), + }; + let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err(); + assert!(!err.to_string().contains("SECRET-SIBLING-TEXT"), "{err}"); + } + + #[test] + fn empty_sibling_parts_are_kept() { + for (hook, part) in each_hook_with_its_part() { + let payload = message_around(part); + let shimmed = to_legacy(hook, &payload).unwrap(); + let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap(); + assert_surroundings_kept(&back); + } + } + + // --- refusals ----------------------------------------------------------- + + #[test] + fn llm_hooks_have_no_legacy_equivalent_and_fail_closed() { + let payload = MessagePayload { + message: Message::text(Role::User, "hello"), + }; + for hook in ["cmf.llm_input", "cmf.llm_output"] { + assert!(is_cmf_hook(hook)); + let err = to_legacy(hook, &payload).unwrap_err(); + assert_shim_error(err, hook, "cmf_hooks"); + } + } + + #[test] + fn a_non_message_payload_fails_closed() { + let payload = ToolPreInvokePayload { + name: "search".into(), + ..Default::default() + }; + let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err(); + assert_shim_error(err, "cmf.tool_pre_invoke", "not a CMF MessagePayload"); + } + + #[test] + fn shim_errors_carry_no_payload_values() { + // Error text reaches logs. A refusal must name shapes, not content. + let payload = MessagePayload { + message: Message::text(Role::User, "SECRET-USER-TEXT"), + }; + let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err(); + assert!(!err.to_string().contains("SECRET-USER-TEXT"), "{err}"); + } +} diff --git a/crates/cpex-hosts-python/src/legacy/mod.rs b/crates/cpex-hosts-python/src/legacy/mod.rs index 4fa718e6..1eb85337 100644 --- a/crates/cpex-hosts-python/src/legacy/mod.rs +++ b/crates/cpex-hosts-python/src/legacy/mod.rs @@ -5,11 +5,17 @@ // // Legacy (non-CMF) hook payloads. // -// "Legacy" is the framework's own term for the pre-CMF hook family — -// `tool_pre_invoke` and friends, as opposed to `cmf.tool_pre_invoke`. Both -// families are supported; they differ only in which payload type a hook name -// resolves to, which is what `crate::conversion` decides. +// "Legacy" is the Python framework's term for the pre-CMF hook family: +// `tool_pre_invoke` and friends, as opposed to `cmf.tool_pre_invoke`. These +// structs mirror its Pydantic models. The identity and delegation payloads +// ride PPE's `identity.resolve` and `token.delegate` hooks. The tool, prompt +// and resource payloads have no PPE hook; they serve hosts that register the +// unprefixed names themselves. `crate::conversion` holds the hook table. +// +// `cmf_shim` translates PPE's tool, prompt and resource `cmf.*` hooks into +// these payloads for workers that predate `cmf.*` hook support. +pub mod cmf_shim; pub mod payloads; pub use payloads::{ diff --git a/crates/cpex-hosts-python/src/legacy/payloads.rs b/crates/cpex-hosts-python/src/legacy/payloads.rs index f6669aa4..fa3601f2 100644 --- a/crates/cpex-hosts-python/src/legacy/payloads.rs +++ b/crates/cpex-hosts-python/src/legacy/payloads.rs @@ -17,7 +17,7 @@ // fields, so both get a hand-written `Debug` that prints a placeholder instead // of the value. A derive would put credential material into any `{:?}` — a // tracing call, an `unwrap()` panic message, an assertion failure. The -// equivalent gap on the pre-existing cpex-core types is deferred follow-up +// equivalent gap on the pre-existing praxis-policy-core types is deferred follow-up // work; these new types do not reintroduce it. use std::collections::HashMap; @@ -41,7 +41,7 @@ pub struct ToolPreInvokePayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option>, } -cpex_core::impl_plugin_payload!(ToolPreInvokePayload); +praxis_policy_core::impl_plugin_payload!(ToolPreInvokePayload); /// `tool_post_invoke` — mirrors `ToolPostInvokePayload`. /// @@ -53,7 +53,7 @@ pub struct ToolPostInvokePayload { #[serde(default)] pub result: serde_json::Value, } -cpex_core::impl_plugin_payload!(ToolPostInvokePayload); +praxis_policy_core::impl_plugin_payload!(ToolPostInvokePayload); /// `prompt_pre_fetch` — mirrors `PromptPrehookPayload`. /// @@ -66,7 +66,7 @@ pub struct PromptPreFetchPayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub args: Option>, } -cpex_core::impl_plugin_payload!(PromptPreFetchPayload); +praxis_policy_core::impl_plugin_payload!(PromptPreFetchPayload); /// `prompt_post_fetch` — mirrors `PromptPosthookPayload`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -76,7 +76,7 @@ pub struct PromptPostFetchPayload { #[serde(default)] pub result: serde_json::Value, } -cpex_core::impl_plugin_payload!(PromptPostFetchPayload); +praxis_policy_core::impl_plugin_payload!(PromptPostFetchPayload); /// `resource_pre_fetch` — mirrors `ResourcePreFetchPayload`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -86,7 +86,7 @@ pub struct ResourcePreFetchPayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata: Option>, } -cpex_core::impl_plugin_payload!(ResourcePreFetchPayload); +praxis_policy_core::impl_plugin_payload!(ResourcePreFetchPayload); /// `resource_post_fetch` — mirrors `ResourcePostFetchPayload`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -96,9 +96,10 @@ pub struct ResourcePostFetchPayload { #[serde(default)] pub content: serde_json::Value, } -cpex_core::impl_plugin_payload!(ResourcePostFetchPayload); +praxis_policy_core::impl_plugin_payload!(ResourcePostFetchPayload); -/// `identity_resolve` — mirrors `IdentityPayload`. +/// `identity.resolve` mirrors `IdentityPayload`. The worker knows the hook as +/// `identity_resolve`. /// /// `raw_token` is `SecretStr` on the Python side and redacts on serialization, /// so the value that travels in *this* field is a placeholder, never the real @@ -130,7 +131,7 @@ pub struct IdentityResolvePayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub client_port: Option, } -cpex_core::impl_plugin_payload!(IdentityResolvePayload); +praxis_policy_core::impl_plugin_payload!(IdentityResolvePayload); impl std::fmt::Debug for IdentityResolvePayload { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -163,7 +164,8 @@ pub struct AttenuationConfig { pub ttl_seconds: Option, } -/// `token_delegate` — mirrors `DelegationPayload`. +/// `token.delegate` mirrors `DelegationPayload`. The worker knows the hook as +/// `token_delegate`. /// /// `bearer_token` is `SecretStr | None` on the Python side and redacts on /// serialization, so this field carries a placeholder. The plaintext travels @@ -196,7 +198,7 @@ pub struct TokenDelegatePayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub bearer_token: Option, } -cpex_core::impl_plugin_payload!(TokenDelegatePayload); +praxis_policy_core::impl_plugin_payload!(TokenDelegatePayload); fn default_target_type() -> String { "tool".to_string() diff --git a/crates/cpex-hosts-python/src/lib.rs b/crates/cpex-hosts-python/src/lib.rs index 560c15b3..0f18a464 100644 --- a/crates/cpex-hosts-python/src/lib.rs +++ b/crates/cpex-hosts-python/src/lib.rs @@ -5,13 +5,13 @@ // // cpex-hosts-python — out-of-process host for existing Python CPEX plugins. // -// The Rust `PluginManager` cannot run a Python plugin on its own. This crate +// The Rust `PolicyEngine` cannot run a Python plugin on its own. This crate // is the Rust counterpart to the Python CLI's `client.py` + `venv_comm.py`: // it builds a per-plugin virtualenv, launches the framework's `worker.py` in // that venv as a long-lived subprocess, and speaks the same // newline-delimited-JSON stdio protocol the Python CLI already uses. Existing // PII filters, identity resolvers, and token delegators keep working while -// the gateway migrates to the Rust manager. +// the gateway migrates to the Rust engine. // // # Topology // @@ -20,7 +20,7 @@ // protocol), and supplies one hook adapter per declared hook: // // ```text -// PluginManager +// PolicyEngine // └── isolated_venv factory ──> IsolatedPythonPlugin ──┬── venv manager // (initialize/shutdown) └── worker client // └── hook adapter (serialize, send, deserialize) │ @@ -35,7 +35,7 @@ // # Lifecycle // // Venv construction and worker launch happen in `initialize()`, which the -// manager awaits once per plugin (with rollback on failure), and teardown in +// engine awaits once per plugin (with rollback on failure), and teardown in // `shutdown()`, which it calls in reverse order. Neither belongs on the // invoke path — a cold pip install is measured in minutes. // @@ -64,6 +64,7 @@ // this channel. See the `extensions` module. pub mod conversion; +pub mod credential_payloads; pub mod credentials; pub mod error; pub mod extensions; @@ -83,6 +84,6 @@ pub use conversion::{GenericPayload, PayloadKind}; pub use credentials::CredentialDto; pub use error::HostError; pub use factory::{IsolatedVenvFactory, KIND}; -pub use plugin::{IsolatedPythonPlugin, VenvConfig, DEFAULT_PLUGIN_DIR}; +pub use plugin::{DEFAULT_PLUGIN_DIR, IsolatedPythonPlugin, VenvConfig}; pub use venv::{CacheVerdict, EnsureOutcome, VenvManager}; pub use worker::WorkerClient; diff --git a/crates/cpex-hosts-python/src/plugin.rs b/crates/cpex-hosts-python/src/plugin.rs index 7cf9ff0c..faaf1ca8 100644 --- a/crates/cpex-hosts-python/src/plugin.rs +++ b/crates/cpex-hosts-python/src/plugin.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use async_trait::async_trait; -use cpex_core::{ +use praxis_policy_core::{ context::PluginContext, error::PluginError, extensions::Extensions, @@ -29,9 +29,10 @@ use cpex_core::{ use serde::Deserialize; use crate::error::HostError; +use crate::legacy::cmf_shim; use crate::venv::VenvManager; use crate::worker::WorkerClient; -use crate::{conversion, credentials}; +use crate::{conversion, credential_payloads, credentials}; /// Default cap on the serialized size of one outbound task, in bytes. /// @@ -85,8 +86,8 @@ const RESPAWN_BUDGET_RESET_SECS: u64 = 60; /// This is the sole source of the worker's `sys.path` entries and of the /// directory the venv is built under. It is *not* configurable per plugin: a /// `plugin_dirs:` key inside a plugin's `config:` block is ignored (see -/// `VenvConfig`), as is the top-level `plugin_dirs:` YAML key, which -/// `cpex_core` already parses and discards. +/// `VenvConfig`). A top-level `plugin_dirs:` YAML key is rejected by the +/// config loader. /// /// # Why a fixed default rather than a config key /// @@ -117,10 +118,12 @@ fn project_root() -> PathBuf { /// if something later changes the process cwd between `initialize()` and an /// invocation. fn default_plugin_dirs() -> Vec { - vec![project_root() - .join(DEFAULT_PLUGIN_DIR) - .display() - .to_string()] + vec![ + project_root() + .join(DEFAULT_PLUGIN_DIR) + .display() + .to_string(), + ] } /// Global-state key a host can set to propagate its own request id into the @@ -167,8 +170,8 @@ const WORKER_KNOWN_CAPABILITIES: &[&str] = &[ /// /// # `plugin_dirs` is deliberately absent /// -/// A `plugin_dirs:` key in this block is **ignored**, as is the top-level -/// `plugin_dirs:` YAML key. Plugin directories are not configurable: the host +/// A `plugin_dirs:` key in this block is **ignored**; a top-level +/// `plugin_dirs:` YAML key fails the config load. Plugin directories are not configurable: the host /// always uses `/plugins`, which is where `cpex plugin install` /// puts them. See `DEFAULT_PLUGIN_DIR`. Serde ignores unknown fields, so an /// existing config that still carries the key parses without error — the value @@ -218,7 +221,7 @@ impl VenvConfig { /// Parse the venv settings out of a plugin entry's `config` map. /// /// A missing or malformed `class_name` is a config error, not a panic — - /// the manager surfaces it against the plugin name at load time. + /// the engine surfaces it against the plugin name at load time. pub fn from_plugin_config(config: &PluginConfig) -> Result> { let raw = config .config @@ -280,6 +283,11 @@ pub struct IsolatedPythonPlugin { /// Respawn bookkeeping for a worker that dies after a successful /// `initialize()`. respawn: tokio::sync::Mutex, + + /// Set by `shutdown()` before it empties the worker slot, and cleared by + /// `initialize()`. A respawn already under way when the plugin shuts down + /// checks it, so it cannot launch and install a worker nothing will stop. + shut_down: std::sync::atomic::AtomicBool, } /// How many respawns this plugin has spent, and when the budget last reset. @@ -349,7 +357,7 @@ impl IsolatedPythonPlugin { // A layout that will not resolve leaves the venv absent. That is a // config error in production, but the failure belongs in // `initialize()` rather than here: the factory should still hand back a - // named plugin the manager can report against. + // named plugin the engine can report against. let venv = VenvManager::new( &plugin_dirs, &venv_config.class_name, @@ -367,9 +375,20 @@ impl IsolatedPythonPlugin { worker_override: None, worker_cwd: None, respawn: tokio::sync::Mutex::new(RespawnState::new()), + shut_down: std::sync::atomic::AtomicBool::new(false), }) } + fn is_shut_down(&self) -> bool { + self.shut_down.load(std::sync::atomic::Ordering::SeqCst) + } + + fn shut_down_error() -> HostError { + HostError::WorkerStart { + message: "the plugin has been shut down, so its worker is not respawned".into(), + } + } + /// The resolved plugin directories — `/plugins`. pub fn plugin_dirs(&self) -> &[String] { &self.plugin_dirs @@ -508,10 +527,12 @@ impl IsolatedPythonPlugin { if worker.is_alive() { return Ok(worker); } + } else if self.is_shut_down() { + return Err(to_plugin_error(Self::shut_down_error())); } else { // Never initialized is not a respawn case — respawning here would // paper over a lifecycle bug (or an initialize() that failed and - // was rolled back) by silently starting a worker the manager does + // was rolled back) by silently starting a worker the engine does // not know about. return Err(to_plugin_error(HostError::WorkerStart { message: "plugin has no worker — initialize() did not run or it failed".into(), @@ -532,12 +553,17 @@ impl IsolatedPythonPlugin { let mut respawn = self.respawn.lock().await; // Re-check under the lock. A concurrent invocation may already have - // done the work while this one waited. - if let Some(worker) = self.worker.read().await.clone() { - if worker.is_alive() { - return Ok(worker); - } - } + // done the work while this one waited, or the plugin may have shut + // down. An empty slot means the latter: a failed respawn leaves the + // dead worker in place, so only `shutdown()` empties it. + let dead = match self.worker.read().await.clone() { + Some(worker) if worker.is_alive() => return Ok(worker), + Some(_) if self.is_shut_down() => { + return Err(to_plugin_error(Self::shut_down_error())); + }, + Some(worker) => worker, + None => return Err(to_plugin_error(Self::shut_down_error())), + }; let Some(attempt) = respawn.try_reserve() else { tracing::error!( @@ -567,12 +593,19 @@ impl IsolatedPythonPlugin { ); tokio::time::sleep(backoff).await; + // The plugin may have shut down during the backoff. + if self.is_shut_down() { + return Err(to_plugin_error(Self::shut_down_error())); + } + // Reap the corpse before replacing it, so the old process and its I/O - // tasks are not left behind on every respawn. - if let Some(dead) = self.worker.write().await.take() { - if let Err(e) = dead.shutdown().await { - tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}"); - } + // tasks are not left behind on every respawn. It stays in the slot + // until a replacement is installed: if the launch below fails, the + // next invocation finds a dead worker and comes back here, within the + // same budget, instead of reading an empty slot as "never initialized" + // and giving up on the plugin for good. + if let Err(e) = dead.shutdown().await { + tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}"); } // Same launch path as `initialize()` — see `launch_worker`. @@ -582,11 +615,23 @@ impl IsolatedPythonPlugin { // fresh process resolving `worker.py` from the venv again, and the venv // can have been rebuilt or downgraded since. Skipping this would make // respawn the one path that installs an unverified worker. - self.verify_worker_understands(&worker) - .await - .map_err(to_plugin_error)?; + if let Err(e) = self.verify_worker_understands(&worker).await { + stop_unused_worker(&self.config.name, &worker).await; + return Err(to_plugin_error(e)); + } - *self.worker.write().await = Some(Arc::clone(&worker)); + // Checked under the slot's write lock, which `shutdown()` takes after + // setting the flag: either this sees the flag, or `shutdown()` waits + // here and then stops the worker installed below. + { + let mut slot = self.worker.write().await; + if self.is_shut_down() { + drop(slot); + stop_unused_worker(&self.config.name, &worker).await; + return Err(to_plugin_error(Self::shut_down_error())); + } + *slot = Some(Arc::clone(&worker)); + } // Deliberately *not* `record_success()` here. // @@ -682,11 +727,42 @@ impl IsolatedPythonPlugin { /// every existing plugin. async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> { let required = self.required_worker_features(); + + // Tool, prompt and resource `cmf.*` hooks also need the report to + // choose native dispatch or the legacy shim. When they require no wire + // feature, an inconclusive probe leaves the report unset and the + // adapter falls back to the shim. LLM hooks require `cmf_hooks` above. + let declares_cmf_hook = self.config.hooks.iter().any(|h| cmf_shim::is_cmf_hook(h)); if required.is_empty() { + if declares_cmf_hook { + match worker.capabilities().await { + Ok(reported) => self.log_cmf_dispatch(&reported), + Err(e) => tracing::warn!( + plugin = %self.config.name, + "worker capabilities probe failed ({e}); serving cmf hooks through the \ + legacy payload shim" + ), + } + } return Ok(()); } - let reported = worker.capabilities().await?; + let reported = worker.capabilities().await.map_err(|e| { + if required.contains(&crate::worker::FEATURE_CMF_HOOKS) { + HostError::Config { + message: format!( + "plugin '{}' declares an LLM hook, but the worker's `cmf_hooks` support \ + could not be verified ({e}); upgrade the cpex package in the plugin's venv", + self.config.name + ), + } + } else { + e + } + })?; + if declares_cmf_hook { + self.log_cmf_dispatch(&reported); + } let missing: Vec<&str> = required .iter() .copied() @@ -740,6 +816,20 @@ impl IsolatedPythonPlugin { }) } + /// Say once, at startup, how this plugin's `cmf.*` hooks will be served. + fn log_cmf_dispatch(&self, reported: &crate::worker::WorkerCapabilities) { + if reported.has(crate::worker::FEATURE_CMF_HOOKS) { + tracing::debug!(plugin = %self.config.name, "worker serves cmf hooks natively"); + } else { + tracing::info!( + plugin = %self.config.name, + "worker does not claim `{}`; serving cmf tool, prompt and resource hooks \ + through the legacy payload shim", + crate::worker::FEATURE_CMF_HOOKS + ); + } + } + /// The wire features this plugin's declaration commits the host to using. /// /// Derived from the declaration rather than from what a given request @@ -748,6 +838,17 @@ impl IsolatedPythonPlugin { fn required_worker_features(&self) -> Vec<&'static str> { let mut required = Vec::new(); + // LLM messages have no legacy worker payload. A worker without native + // CMF dispatch cannot serve these hooks even if it needs no capability. + if self + .config + .hooks + .iter() + .any(|h| matches!(h.as_str(), "cmf.llm_input" | "cmf.llm_output")) + { + required.push(crate::worker::FEATURE_CMF_HOOKS); + } + // A credential hook only receives material when the matching capability // is declared, so both halves are required for the field to travel — // which makes the capability the precise trigger. @@ -781,9 +882,13 @@ impl IsolatedPythonPlugin { /// `PluginConfig(**config_raw)`, and `context` is the worker's own /// `{state, global_context, metadata}` shape rather than the Rust /// `PluginContext` layout. + /// + /// `legacy_cmf` selects the legacy payload shim's hook names for the + /// `cmf.*` hooks: true for a worker that does not claim `cmf_hooks`. fn build_task( &self, hook_name: &str, + legacy_cmf: bool, payload: serde_json::Value, ctx: &PluginContext, ) -> Result { @@ -799,10 +904,19 @@ impl IsolatedPythonPlugin { // `security.labels` empty and `http` as `None` no matter what it // declared. The two filters are now given the same input, so the second // one is idempotent rather than destructive. + // Hook names travel in the spelling the worker registers, which differs + // from PPE's for the identity, delegation and CMF prompt hooks, and for + // every shimmed CMF hook. + let worker_hooks: Vec<&str> = self + .config + .hooks + .iter() + .map(|hook| worker_hook_for(hook, legacy_cmf)) + .collect(); let config_json = serde_json::json!({ "name": self.config.name, "kind": self.config.kind, - "hooks": self.config.hooks, + "hooks": worker_hooks, "capabilities": self.worker_capabilities(), "config": self.config.config.clone().unwrap_or(serde_json::Value::Null), }); @@ -826,7 +940,7 @@ impl IsolatedPythonPlugin { "plugin_dirs": self.plugin_dirs, "class_name": self.venv_config.class_name, "config": config_string, - "hook_type": hook_name, + "hook_type": worker_hook_for(hook_name, legacy_cmf), "plugin_name": self.config.name, "payload": payload, "context": { @@ -859,9 +973,11 @@ impl Plugin for IsolatedPythonPlugin { /// Build the venv (or reuse a cached one) and launch the worker. /// - /// Heavy setup lives here rather than on the invoke path: the manager + /// Heavy setup lives here rather than on the invoke path: the engine /// awaits this once per plugin, with rollback on failure. async fn initialize(&self) -> Result<(), Box> { + self.shut_down + .store(false, std::sync::atomic::Ordering::SeqCst); let worker = self .launch_worker() .await @@ -882,6 +998,10 @@ impl Plugin for IsolatedPythonPlugin { /// Stop the worker, killing it if it will not exit on its own. async fn shutdown(&self) -> Result<(), Box> { + // Before the slot is emptied, so a respawn in flight sees it. See + // `respawn_worker`. + self.shut_down + .store(true, std::sync::atomic::Ordering::SeqCst); let worker = self.worker.write().await.take(); let result = match worker { Some(worker) => worker @@ -895,19 +1015,44 @@ impl Plugin for IsolatedPythonPlugin { // rebuild is not permanently downgraded to an in-place update by a stale // owner entry. Best-effort: a registry write failure must not turn a // clean shutdown into an error, since the venv itself is fine. - if let Some(venv) = self.venv.as_ref() { - if let Err(e) = venv.release_owner().await { - tracing::warn!( - plugin = %self.config.name, - "could not release the venv owner claim: {e}" - ); - } + if let Some(venv) = self.venv.as_ref() + && let Err(e) = venv.release_owner().await + { + tracing::warn!( + plugin = %self.config.name, + "could not release the venv owner claim: {e}" + ); } result } } +/// Stop a worker that was launched but will not be installed. +async fn stop_unused_worker(plugin: &str, worker: &WorkerClient) { + if let Err(e) = worker.shutdown().await { + tracing::debug!(plugin = %plugin, "stopping an unused worker: {e}"); + } +} + +/// The hook name the worker knows `hook_name` by, given how `cmf.*` hooks are +/// being served. +fn worker_hook_for(hook_name: &str, legacy_cmf: bool) -> &str { + if legacy_cmf && let Some(legacy) = cmf_shim::legacy_hook_name(hook_name) { + return legacy; + } + conversion::worker_hook_name(hook_name) +} + +/// Whether `worker` must be served `cmf.*` hooks through the legacy payload +/// shim: true unless its handshake claimed `cmf_hooks`. A worker that was never +/// asked, or could not answer, gets the shim. +fn needs_legacy_cmf(worker: &WorkerClient) -> bool { + !worker + .reported_capabilities() + .is_some_and(|c| c.has(crate::worker::FEATURE_CMF_HOOKS)) +} + /// Resolve an absolute `python3` from PATH. /// /// `WorkerClient::spawn` takes an interpreter path rather than doing a PATH @@ -972,17 +1117,53 @@ impl AnyHookHandler for PythonHookAdapter { let plugin_name = &self.plugin.config.name; let to_plugin_error = |e: HostError| e.into_plugin_error(plugin_name); - let payload_json = conversion::serialize_payload(payload).map_err(to_plugin_error)?; + // The worker first: whether it serves `cmf.*` hooks natively decides + // what the task carries, and a respawn inside `worker()` may replace it + // with one that answers differently. + let worker = self.plugin.worker().await?; + + // A probe that failed at startup leaves no report, which would pin + // this worker to the shim for its whole life. Ask once more, the first + // time a `cmf.*` hook needs the answer. + if cmf_shim::is_cmf_hook(self.hook_name) + && worker.reported_capabilities().is_none() + && worker.claim_probe_retry() + { + match worker.capabilities().await { + Ok(reported) => self.plugin.log_cmf_dispatch(&reported), + Err(e) => tracing::warn!( + plugin = %plugin_name, + "worker capabilities probe failed again ({e}); serving cmf hooks through \ + the legacy payload shim for this worker" + ), + } + } + let legacy_cmf = needs_legacy_cmf(&worker); + + // A worker that predates `cmf.*` hooks gets the legacy typed payload + // under the legacy hook name. A message the legacy payload cannot + // represent, and the LLM hooks, fail closed here. + let shim = if legacy_cmf && cmf_shim::is_cmf_hook(self.hook_name) { + Some(cmf_shim::to_legacy(self.hook_name, payload).map_err(to_plugin_error)?) + } else { + None + }; + + let payload_json = match shim.as_ref() { + Some(shim) => shim.payload().clone(), + None => conversion::serialize_payload(payload).map_err(to_plugin_error)?, + }; let mut task = self .plugin - .build_task(self.hook_name, payload_json, ctx) + .build_task(self.hook_name, legacy_cmf, payload_json, ctx) .map_err(to_plugin_error)?; // Raw credentials, for the two hooks that carry them and only for a // plugin that declared the matching capability. Fails closed. - credentials::attach_credential( + credentials::attach_credential_for_payload( &mut task, self.hook_name, + payload, extensions, &self.plugin.config.capabilities, ) @@ -993,8 +1174,7 @@ impl AnyHookHandler for PythonHookAdapter { // would see in-process. Attaches nothing when no slot is visible. crate::extensions::attach_extensions(&mut task, extensions).map_err(to_plugin_error)?; - let worker = self.plugin.worker().await?; - let response = worker.send_task(task).await.map_err(to_plugin_error)?; + let mut response = worker.send_task(task).await.map_err(to_plugin_error)?; // A completed round trip is the only proof a (possibly respawned) worker // actually serves requests, so it is what returns the respawn budget. @@ -1004,20 +1184,93 @@ impl AnyHookHandler for PythonHookAdapter { // before the result is returned, so a plugin's context writes survive. apply_context_deltas(&response, ctx); + // Under the shim the modified payload is a legacy one, so it is taken + // out before the generic conversion (which would parse it as a CMF + // message) and mapped back onto the original message afterwards. + // Allow, deny and the violation are converted unchanged. + // + // PPE's identity and delegation payloads take the same route: the + // plugin answers with the worker's result model, which is mapped onto + // a clone of the dispatched payload rather than parsed into a type + // PPE's own downcasts would not find. + let translated = shim.is_some() || credential_payloads::is_ppe_credential_payload(payload); + let returned_modified = if translated { + response + .as_object_mut() + .and_then(|r| r.remove("modified_payload")) + } else { + None + }; + // `extensions` is passed back in so the returned-extensions path can // reuse the inbound `Arc`s for immutable slots and read the write // tokens the executor issued. The executor's copy-on-write merge then // validates the result against the mutability tiers. - let fields = conversion::response_to_result(self.hook_name, response, extensions) + let mut fields = conversion::response_to_result(self.hook_name, response, extensions) .map_err(to_plugin_error)?; + + if let Some(modified) = returned_modified.filter(|m| !m.is_null()) { + apply_translated_modification(shim.as_ref(), payload, modified, &mut fields) + .map_err(to_plugin_error)?; + } Ok(Box::new(fields)) } + /// The hook family the declared name belongs to, so the registry's family + /// check accepts this handler. The adapter forwards whatever payload the + /// hook carries, so it serves the family of any name it is declared on. A + /// name with no family in the hook metadata reports itself, which the + /// registry accepts. fn hook_type_name(&self) -> &'static str { - self.hook_name + praxis_policy_core::hooks::lookup_hook_metadata(self.hook_name) + .and_then(|meta| meta.family) + .unwrap_or(self.hook_name) } } +/// Map a modified payload the worker returned in its own model back onto the +/// payload PPE dispatched, for the shimmed `cmf.*` hooks and for PPE's +/// identity and delegation payloads. +fn apply_translated_modification( + shim: Option<&cmf_shim::ShimmedPayload>, + payload: &dyn PluginPayload, + modified: serde_json::Value, + fields: &mut praxis_policy_core::executor::ErasedResultFields, +) -> Result<(), HostError> { + use praxis_policy_core::delegation::DelegationPayload; + use praxis_policy_core::identity::IdentityPayload; + + // A deny modifies nothing, so a result the plugin attached to one is not + // worth failing the deny over. + if !fields.continue_processing { + fields.modified_payload = None; + return Ok(()); + } + if let Some(shim) = shim { + fields.modified_payload = Some(Box::new(shim.apply_modified(modified)?)); + return Ok(()); + } + let any = payload.as_any(); + if let Some(identity) = any.downcast_ref::() { + match credential_payloads::identity_from_worker(identity, modified)? { + credential_payloads::IdentityOutcome::Resolved(resolved) => { + fields.modified_payload = Some(resolved); + }, + credential_payloads::IdentityOutcome::Rejected(violation) => { + // A rejection is a deny, and a deny modifies nothing. + fields.modified_payload = None; + fields.continue_processing = false; + fields.violation = Some(violation); + }, + } + } else if let Some(delegation) = any.downcast_ref::() { + fields.modified_payload = Some(Box::new(credential_payloads::delegation_from_worker( + delegation, modified, + )?)); + } + Ok(()) +} + /// Merge worker-returned context state back into the mutable plugin context. /// /// The worker returns the context under the same `{state, global_context}` @@ -1185,32 +1438,86 @@ mod tests { } #[test] - fn the_top_level_plugin_dirs_key_is_also_ignored() { - // `cpex plugin install` writes the key here. cpex_core parses and - // discards it; this pins that it does not reach the host either. - let yaml = r#" -plugin_dirs: ["./top-level-dirs"] -plugins: - - name: py-plugin - kind: isolated_venv - hooks: [tool_pre_invoke] - config: - class_name: my_pkg.Minimal -"#; - let cpex_config: cpex_core::config::CpexConfig = - serde_yaml::from_str(yaml).expect("valid YAML"); + fn ppe_hook_names_reach_the_worker_in_its_own_spelling() { + // The Python framework keys payload models, hook methods and credential + // handling on the older names, so the task's `hook_type` and the + // config's `hooks` list both carry the worker's spelling. + let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"})); + config.hooks = vec![ + "identity.resolve".into(), + "token.delegate".into(), + "cmf.prompt_pre_invoke".into(), + "cmf.tool_pre_invoke".into(), + ]; + let plugin = IsolatedPythonPlugin::from_config(&config).expect("parses"); + + for (dispatched, worker) in [ + ("identity.resolve", "identity_resolve"), + ("token.delegate", "token_delegate"), + ("cmf.prompt_pre_invoke", "cmf.prompt_pre_fetch"), + ("cmf.tool_pre_invoke", "cmf.tool_pre_invoke"), + ] { + let task = plugin + .build_task( + dispatched, + false, + serde_json::json!({}), + &PluginContext::new(), + ) + .expect("task builds"); + assert_eq!(task["hook_type"], worker, "hook_type for {dispatched}"); + } + + let task = plugin + .build_task( + "identity.resolve", + false, + serde_json::json!({}), + &PluginContext::new(), + ) + .expect("task builds"); assert_eq!( - cpex_config.plugin_dirs, - vec!["./top-level-dirs"], - "the top-level key still parses — it is simply not the host's source" + worker_config(&task)["hooks"], + serde_json::json!([ + "identity_resolve", + "token_delegate", + "cmf.prompt_pre_fetch", + "cmf.tool_pre_invoke" + ]) ); + } - let plugin = - IsolatedPythonPlugin::from_config(&cpex_config.plugins[0]).expect("config parses"); + #[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"})); + config.hooks = vec![ + "cmf.tool_pre_invoke".into(), + "cmf.prompt_post_invoke".into(), + "cmf.resource_pre_fetch".into(), + "cmf.llm_input".into(), + "identity.resolve".into(), + ]; + let plugin = IsolatedPythonPlugin::from_config(&config).expect("parses"); + + let task = plugin + .build_task( + "cmf.prompt_post_invoke", + true, + serde_json::json!({}), + &PluginContext::new(), + ) + .expect("task builds"); + assert_eq!(task["hook_type"], "prompt_post_fetch"); assert_eq!( - plugin.plugin_dirs(), - [expected_default_dir()], - "the top-level key must not steer the host's plugin dir" + worker_config(&task)["hooks"], + serde_json::json!([ + "tool_pre_invoke", + "prompt_post_fetch", + "resource_pre_fetch", + // No legacy name; the adapter refuses it before any task is built. + "cmf.llm_input", + "identity_resolve" + ]) ); } @@ -1228,6 +1535,7 @@ plugins: let task = plugin .build_task( "tool_pre_invoke", + false, serde_json::json!({"name": "t"}), &PluginContext::new(), ) @@ -1255,6 +1563,7 @@ plugins: .expect("parses") .build_task( "tool_pre_invoke", + false, serde_json::json!({"name": "t"}), &PluginContext::new(), ) @@ -1339,6 +1648,20 @@ def mode_file(name, default=""): def mode(): return mode_file("mode", "allow") +def append(name, text): + with open(os.path.join(here, name), "a") as f: + f.write(text + "\n") + +def count(name): + try: + with open(os.path.join(here, name)) as f: + return len(f.read().splitlines()) + except FileNotFoundError: + return 0 + +# One line per launched process, so a test can tell whether a worker started. +append("spawns", str(os.getpid())) + while True: line = sys.stdin.readline() if not line: @@ -1359,11 +1682,20 @@ while True: # real one implements; `handshake_mode` covers the old-worker case. if task.get("task_type") == "capabilities": hs = mode_file("handshake_mode") + append("probes", hs) + # An over-limit reply fails the probe the way a transient transport + # fault does, without the delay a timeout would cost. `flaky_cmf` fails + # only the first probe; `broken` fails every one. + if hs == "broken" or (hs == "flaky_cmf" and count("probes") == 1): + print(json.dumps({"request_id": rid, "pad": "x" * 8192}), flush=True) + continue if hs == "unsupported": # Exactly what a worker predating the handshake replies. print(json.dumps({"status": "error", "message": "task type not supported.", "request_id": rid}), flush=True) continue features = [] if hs == "no_features" else ["credential", "extensions", "modified_extensions"] + if hs in ("cmf", "flaky_cmf"): + features.append("cmf_hooks") print(json.dumps({ "status": "success", "protocol_version": 1, "features": features, "cpex_version": "0.1.2", "request_id": rid, @@ -1386,11 +1718,13 @@ while True: if m == "die": sys.exit(9) - if m == "deny": + if m in ("deny", "deny_unmappable"): response = { "continue_processing": False, "violation": {"code": "PII_DETECTED", "reason": "email in args", "details": {"field": "q"}}, } + if m == "deny_unmappable": + response["modified_payload"] = {"headers": {"authorization": "invalid"}} elif m == "error": response = {"status": "error", "message": "the plugin raised ValueError"} elif m == "context": @@ -1403,6 +1737,34 @@ while True: "continue_processing": True, "modified_payload": {"name": "search", "args": {"q": "[REDACTED]"}}, } + elif m == "identity_result": + # What the worker dumps for a Python IdentityResult. + response = { + "continue_processing": True, + "modified_payload": { + "subject": {"id": "alice", "type": "user", "roles": ["hr"], + "permissions": [], "teams": [], "claims": {}}, + "delegation": None, "rejected": False, "reject_status": 401, + "reject_reason": "", "raw_claims": {"sub": "alice"}, + }, + } + elif m == "identity_rejected": + response = { + "continue_processing": True, + "modified_payload": {"subject": None, "rejected": True, + "reject_status": 401, "reject_reason": "token expired"}, + } + elif m == "delegation_result": + response = { + "continue_processing": True, + "modified_payload": { + "delegated_token": "STUB-MINTED-TOKEN", + "delegation_update": {"chain": [], "depth": 0, "origin_subject_id": None, + "actor_subject_id": None, "delegated": False, + "age_seconds": 0.0}, + "forwarded_headers": {}, "cache_key": None, "cache_ttl": None, + }, + } else: response = {"continue_processing": True} @@ -1440,6 +1802,19 @@ while True: fn uninitialized_adapter_plugin( hooks: &[&str], capabilities: &[&str], + ) -> (TempDir, Arc) { + uninitialized_adapter_plugin_with( + hooks, + capabilities, + serde_json::json!({ "class_name": "my_pkg.Plugin" }), + ) + } + + /// [`uninitialized_adapter_plugin`] with an explicit `config:` block. + fn uninitialized_adapter_plugin_with( + hooks: &[&str], + capabilities: &[&str], + config_block: serde_json::Value, ) -> (TempDir, Arc) { let dir = TempDir::new(); let script = dir.path().join("adapter_stub.py"); @@ -1450,7 +1825,7 @@ while True: kind: crate::factory::KIND.into(), hooks: hooks.iter().map(|h| (*h).to_string()).collect(), capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(), - config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })), + config: Some(config_block), ..Default::default() }; @@ -1474,10 +1849,10 @@ while True: hook: &'static str, payload: &dyn PluginPayload, ctx: &mut PluginContext, - ) -> Result> { + ) -> Result> { let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook); let erased = adapter.invoke(payload, &Extensions::default(), ctx).await?; - Ok(cpex_core::executor::extract_erased(erased) + Ok(praxis_policy_core::executor::extract_erased(erased) .expect("the adapter returns ErasedResultFields")) } @@ -1655,15 +2030,23 @@ while True: } #[tokio::test] - async fn a_cmf_hook_sends_the_message_payload_shape() { - if crate::testing::skip_without_python3("a_cmf_hook_sends_the_message_payload_shape") { + async fn a_cmf_hook_sends_the_message_payload_shape_to_a_cmf_worker() { + if crate::testing::skip_without_python3( + "a_cmf_hook_sends_the_message_payload_shape_to_a_cmf_worker", + ) { return; } - let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await; + let (dir, plugin) = uninitialized_adapter_plugin(&["cmf.tool_pre_invoke"], &[]); + // A worker claiming `cmf_hooks` gets the CMF message, not the shim. + set_handshake_mode(&dir, "cmf"); + plugin.initialize().await.expect("the stub worker launches"); set_mode(&dir, "allow"); - let payload = cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text(cpex_core::cmf::Role::User, "hello"), + let payload = praxis_policy_core::cmf::MessagePayload { + message: praxis_policy_core::cmf::Message::text( + praxis_policy_core::cmf::Role::User, + "hello", + ), }; let mut ctx = PluginContext::new(); invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx) @@ -1680,6 +2063,177 @@ while True: plugin.shutdown().await.unwrap(); } + /// A CMF tool call after an empty text part, for the shim dispatch tests. + /// The shim keeps the empty sibling part as it was. + fn cmf_tool_call(q: &str) -> praxis_policy_core::cmf::MessagePayload { + use praxis_policy_core::cmf::{ContentPart, Message, Role, ToolCall}; + praxis_policy_core::cmf::MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ + ContentPart::Text { + text: String::new(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-7".into(), + name: "search".into(), + arguments: std::collections::HashMap::from([( + "q".into(), + serde_json::json!(q), + )]), + namespace: None, + }, + }, + ], + ), + } + } + + #[tokio::test] + async fn a_cmf_hook_reaches_an_old_worker_as_the_legacy_payload() { + if crate::testing::skip_without_python3( + "a_cmf_hook_reaches_an_old_worker_as_the_legacy_payload", + ) { + return; + } + // The default stub handshake claims no `cmf_hooks`, like a 0.1.x worker. + let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await; + set_mode(&dir, "allow"); + + let mut ctx = PluginContext::new(); + let fields = invoke( + &plugin, + "cmf.tool_pre_invoke", + &cmf_tool_call("rust"), + &mut ctx, + ) + .await + .expect("the shimmed round trip succeeds"); + assert!(fields.continue_processing); + assert!(fields.modified_payload.is_none()); + + let task = last_task(&dir); + assert_eq!(task["hook_type"], "tool_pre_invoke"); + assert_eq!( + task["payload"], + serde_json::json!({"name": "search", "args": {"q": "rust"}}) + ); + assert_eq!( + worker_config(&task)["hooks"], + serde_json::json!(["tool_pre_invoke"]) + ); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_legacy_modification_maps_back_onto_the_cmf_message() { + if crate::testing::skip_without_python3( + "a_legacy_modification_maps_back_onto_the_cmf_message", + ) { + return; + } + let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await; + // Returns `{"name": "search", "args": {"q": "[REDACTED]"}}`. + set_mode(&dir, "modify"); + + let mut ctx = PluginContext::new(); + let fields = invoke( + &plugin, + "cmf.tool_pre_invoke", + &cmf_tool_call("alice@example.com"), + &mut ctx, + ) + .await + .unwrap(); + + let modified = fields.modified_payload.expect("the modification survives"); + let message = modified + .as_any() + .downcast_ref::() + .expect("a cmf hook's modification must come back as a MessagePayload"); + let json = serde_json::to_value(message).unwrap(); + assert_eq!(json["message"]["content"][0]["text"], ""); + assert_eq!( + json["message"]["content"][1]["content"]["arguments"]["q"], + "[REDACTED]" + ); + assert_eq!( + json["message"]["content"][1]["content"]["tool_call_id"], + "call-7" + ); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_legacy_deny_passes_through_the_shim_unchanged() { + if crate::testing::skip_without_python3("a_legacy_deny_passes_through_the_shim_unchanged") { + return; + } + let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await; + for mode in ["deny", "deny_unmappable"] { + set_mode(&dir, mode); + let mut ctx = PluginContext::new(); + let fields = invoke( + &plugin, + "cmf.tool_pre_invoke", + &cmf_tool_call("x"), + &mut ctx, + ) + .await + .expect("an unusable modification must not replace a denial with an error"); + + assert!(!fields.continue_processing); + assert!(fields.modified_payload.is_none()); + let violation = fields.violation.expect("the deny keeps its violation"); + assert_eq!(violation.code, "PII_DETECTED"); + assert_eq!(violation.details["field"], "q"); + } + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn an_unrepresentable_message_fails_closed_before_reaching_the_worker() { + if crate::testing::skip_without_python3( + "an_unrepresentable_message_fails_closed_before_reaching_the_worker", + ) { + return; + } + let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await; + set_mode(&dir, "allow"); + + let no_tool_call = praxis_policy_core::cmf::MessagePayload { + message: praxis_policy_core::cmf::Message::text( + praxis_policy_core::cmf::Role::User, + "hello", + ), + }; + for hook in ["cmf.tool_pre_invoke"] { + let mut ctx = PluginContext::new(); + let Err(err) = invoke(&plugin, hook, &no_tool_call, &mut ctx).await else { + panic!("'{hook}' must fail closed rather than allow"); + }; + let message = err.to_string(); + assert!(message.contains(hook), "names the hook: {message}"); + assert!(message.contains("py-plugin"), "names the plugin: {message}"); + match *err { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("legacy_shim_unsupported")); + }, + ref other => panic!("expected an Execution error, got {other:?}"), + } + } + assert!( + !dir.path().join("last_task.json").exists(), + "nothing may reach the worker for a message the shim refused" + ); + + plugin.shutdown().await.unwrap(); + } + #[tokio::test] async fn invoking_before_initialize_is_a_plugin_error_not_a_panic() { let config = PluginConfig { @@ -1711,12 +2265,12 @@ while True: ) { return; } - use cpex_core::extensions::raw_credentials::{ + use praxis_policy_core::extensions::raw_credentials::{ RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; let (dir, plugin) = - adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await; + adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await; set_mode(&dir, "allow"); let mut raw = RawCredentialsExtension::default(); @@ -1731,7 +2285,7 @@ while True: let payload = crate::legacy::IdentityResolvePayload::default(); let mut ctx = PluginContext::new(); - let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve"); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve"); adapter .invoke(&payload, &extensions, &mut ctx) .await @@ -1740,6 +2294,63 @@ while True: let task = last_task(&dir); assert_eq!(task["credential"]["inbound"]["token"], "E2E-SECRET-TOKEN"); assert_eq!(task["credential"]["inbound"]["kind"], "jwt"); + assert_eq!( + task["hook_type"], "identity_resolve", + "the worker registers the identity hook under its own spelling" + ); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_credential_capability_attaches_the_dto_on_a_delegation_hook() { + if crate::testing::skip_without_python3( + "a_credential_capability_attaches_the_dto_on_a_delegation_hook", + ) { + return; + } + use praxis_policy_core::extensions::raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + }; + + let (dir, plugin) = adapter_plugin(&["token.delegate"], &["read_delegated_tokens"]).await; + set_mode(&dir, "allow"); + + let audience = "https://billing.example.com"; + let mut raw = RawCredentialsExtension::default(); + raw.delegated_tokens.insert( + DelegationKey::new(DelegationMode::OnBehalfOfUser, audience, vec![]), + RawDelegatedToken::new( + "E2E-DELEGATED-TOKEN", + "Authorization", + audience, + vec![], + chrono::Utc::now(), + ), + ); + let extensions = Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + }; + + let payload = crate::legacy::TokenDelegatePayload { + target_name: "billing".into(), + target_audience: Some(audience.into()), + ..Default::default() + }; + let mut ctx = PluginContext::new(); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "token.delegate"); + adapter + .invoke(&payload, &extensions, &mut ctx) + .await + .expect("dispatch succeeds"); + + let task = last_task(&dir); + assert_eq!( + task["credential"]["delegated"]["token"], + "E2E-DELEGATED-TOKEN" + ); + assert_eq!(task["hook_type"], "token_delegate"); plugin.shutdown().await.unwrap(); } @@ -1751,12 +2362,12 @@ while True: ) { return; } - use cpex_core::extensions::raw_credentials::{ + use praxis_policy_core::extensions::raw_credentials::{ RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; // Same hook, same request — this plugin simply declared nothing. - let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await; + let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await; set_mode(&dir, "allow"); let mut raw = RawCredentialsExtension::default(); @@ -1771,7 +2382,7 @@ while True: let payload = crate::legacy::IdentityResolvePayload::default(); let mut ctx = PluginContext::new(); - let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve"); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve"); adapter .invoke(&payload, &extensions, &mut ctx) .await @@ -1880,7 +2491,7 @@ while True: return; } let (dir, plugin) = - uninitialized_adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]); + uninitialized_adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]); // A worker predating the handshake: replies "task type not supported." set_handshake_mode(&dir, "unsupported"); @@ -1907,6 +2518,34 @@ while True: ); } + #[tokio::test] + async fn llm_hooks_require_a_worker_with_native_cmf_support() { + if crate::testing::skip_without_python3( + "llm_hooks_require_a_worker_with_native_cmf_support", + ) { + return; + } + for hook in ["cmf.llm_input", "cmf.llm_output"] { + for handshake in ["no_features", "unsupported"] { + let (dir, plugin) = uninitialized_adapter_plugin(&[hook], &[]); + set_handshake_mode(&dir, handshake); + let err = plugin + .initialize() + .await + .expect_err("the shim cannot serve an LLM hook"); + assert!(err.to_string().contains("cmf_hooks"), "{err}"); + assert!(plugin.worker.read().await.is_none()); + } + let (dir, plugin) = uninitialized_adapter_plugin(&[hook], &[]); + set_handshake_mode(&dir, "cmf"); + plugin + .initialize() + .await + .expect("a native CMF worker can serve an LLM hook"); + plugin.shutdown().await.unwrap(); + } + } + #[tokio::test] async fn a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker() { // The other half of the rule: the handshake must not become a blanket @@ -1979,12 +2618,12 @@ while True: ) { return; } - use cpex_core::extensions::raw_credentials::{ + use praxis_policy_core::extensions::raw_credentials::{ RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; let (dir, plugin) = - adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await; + adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await; set_mode(&dir, "allow"); let mut raw = RawCredentialsExtension::default(); @@ -1998,7 +2637,7 @@ while True: }; let payload = crate::legacy::IdentityResolvePayload::default(); - let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve"); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve"); let mut ctx = PluginContext::new(); adapter @@ -2044,11 +2683,11 @@ while True: ) { return; } - use cpex_core::extensions::raw_credentials::{ + use praxis_policy_core::extensions::raw_credentials::{ RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; - let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await; + let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await; set_mode(&dir, "allow"); let mut raw = RawCredentialsExtension::default(); @@ -2062,7 +2701,7 @@ while True: }; let payload = crate::legacy::IdentityResolvePayload::default(); - let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve"); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve"); let mut ctx = PluginContext::new(); adapter @@ -2259,6 +2898,352 @@ while True: plugin.shutdown().await.unwrap(); } + /// Lines in a file the stub appends to, or 0 when it has none. + fn lines_in(dir: &TempDir, name: &str) -> usize { + std::fs::read_to_string(dir.path().join(name)) + .map(|s| s.lines().count()) + .unwrap_or(0) + } + + #[tokio::test] + async fn a_failed_respawn_is_retried_on_the_next_invocation() { + // A respawn whose replacement fails verification used to leave the + // slot empty, which every later call read as "initialize() did not + // run": one bad respawn disabled the plugin for good. + if crate::testing::skip_without_python3( + "a_failed_respawn_is_retried_on_the_next_invocation", + ) { + return; + } + // `read_labels` makes verification require the `extensions` feature. + let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &["read_labels"]).await; + set_mode(&dir, "allow"); + let payload = crate::legacy::ToolPreInvokePayload { + name: "search".into(), + ..Default::default() + }; + let mut ctx = PluginContext::new(); + invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx) + .await + .unwrap(); + let original_pid = last_pid(&dir); + + set_mode(&dir, "die"); + let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await; + await_worker_death(&plugin).await; + + // The replacement launches but fails the handshake. + set_handshake_mode(&dir, "no_features"); + set_mode(&dir, "allow"); + let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else { + panic!("a replacement that fails verification must not serve the request"); + }; + assert!( + err.to_string().contains("does not implement extensions"), + "{err}" + ); + assert!( + plugin.worker.read().await.is_some(), + "the dead worker stays in the slot so a later call can respawn again" + ); + + // The cause is fixed; the next call respawns within the same budget. + std::fs::remove_file(dir.path().join("handshake_mode")).unwrap(); + let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx) + .await + .expect("a later invocation retries the respawn"); + assert!(fields.continue_processing); + assert_ne!(original_pid, last_pid(&dir), "served by a new process"); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_respawn_racing_shutdown_launches_no_worker() { + // A request that found the worker dead is in the respawn backoff when + // the engine shuts the plugin down. The respawn must give up rather + // than start a worker after shutdown, which nothing would stop. + if crate::testing::skip_without_python3("a_respawn_racing_shutdown_launches_no_worker") { + return; + } + let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await; + set_mode(&dir, "allow"); + let payload = crate::legacy::ToolPreInvokePayload { + name: "search".into(), + ..Default::default() + }; + let mut ctx = PluginContext::new(); + invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx) + .await + .unwrap(); + set_mode(&dir, "die"); + let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await; + await_worker_death(&plugin).await; + set_mode(&dir, "allow"); + assert_eq!(lines_in(&dir, "spawns"), 1); + + let racing = { + let plugin = Arc::clone(&plugin); + tokio::spawn(async move { + let payload = crate::legacy::ToolPreInvokePayload { + name: "search".into(), + ..Default::default() + }; + let mut ctx = PluginContext::new(); + invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx) + .await + .map(|_| ()) + }) + }; + // Inside the first respawn's 100ms backoff. + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + plugin.shutdown().await.unwrap(); + + let Err(err) = racing.await.unwrap() else { + panic!("an invocation racing shutdown must not be served by a new worker"); + }; + assert!(err.to_string().contains("shut down"), "{err}"); + + // Give a wrongly launched worker time to show up before asserting none did. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert_eq!( + lines_in(&dir, "spawns"), + 1, + "no worker launched after shutdown" + ); + assert!(plugin.worker.read().await.is_none(), "the slot stays empty"); + + // And later calls report the shutdown rather than respawning. + let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else { + panic!("a shut-down plugin cannot serve a request"); + }; + assert!(err.to_string().contains("shut down"), "{err}"); + } + + #[tokio::test] + async fn a_transient_probe_failure_is_retried_before_choosing_the_shim() { + if crate::testing::skip_without_python3( + "a_transient_probe_failure_is_retried_before_choosing_the_shim", + ) { + return; + } + // A small frame cap lets the stub fail a probe with an over-limit reply. + let (dir, plugin) = uninitialized_adapter_plugin_with( + &["cmf.tool_pre_invoke"], + &[], + serde_json::json!({ "class_name": "my_pkg.Plugin", "max_content_size": 4096 }), + ); + // The startup probe fails; any later one claims `cmf_hooks`. + set_handshake_mode(&dir, "flaky_cmf"); + plugin + .initialize() + .await + .expect("a failed probe does not stop a plugin that requires nothing"); + set_mode(&dir, "allow"); + + let payload = praxis_policy_core::cmf::MessagePayload { + message: praxis_policy_core::cmf::Message::text( + praxis_policy_core::cmf::Role::User, + "hello", + ), + }; + let mut ctx = PluginContext::new(); + invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx) + .await + .expect("served natively once the retried probe answers"); + + let task = last_task(&dir); + assert_eq!( + task["hook_type"], "cmf.tool_pre_invoke", + "the retried probe's report chooses native dispatch, not the shim" + ); + assert_eq!(lines_in(&dir, "probes"), 2); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_probe_that_keeps_failing_is_retried_once_per_worker() { + if crate::testing::skip_without_python3( + "a_probe_that_keeps_failing_is_retried_once_per_worker", + ) { + return; + } + let (dir, plugin) = uninitialized_adapter_plugin_with( + &["cmf.tool_pre_invoke"], + &[], + serde_json::json!({ "class_name": "my_pkg.Plugin", "max_content_size": 4096 }), + ); + set_handshake_mode(&dir, "broken"); + plugin.initialize().await.unwrap(); + set_mode(&dir, "allow"); + + for _ in 0..3 { + let mut ctx = PluginContext::new(); + invoke( + &plugin, + "cmf.tool_pre_invoke", + &cmf_tool_call("rust"), + &mut ctx, + ) + .await + .expect("the shim still serves the hook"); + assert_eq!(last_task(&dir)["hook_type"], "tool_pre_invoke"); + } + assert_eq!( + lines_in(&dir, "probes"), + 2, + "one startup probe and one retry, not one per call" + ); + + plugin.shutdown().await.unwrap(); + } + + // --- PPE identity and delegation payloads --------------------------------- + + fn ppe_identity() -> praxis_policy_core::identity::IdentityPayload { + use praxis_policy_core::identity::{IdentityPayload, TokenSource}; + IdentityPayload::new("PPE-IDENTITY-SECRET-TOKEN", TokenSource::Bearer).with_headers( + std::collections::HashMap::from([ + ( + "authorization".into(), + "Bearer PPE-IDENTITY-SECRET-TOKEN".into(), + ), + ("cookie".into(), "session=PPE-COOKIE-SECRET".into()), + ("x-tenant".into(), "acme".into()), + ]), + ) + } + + async fn invoke_on( + plugin: &Arc, + hook: &'static str, + payload: &dyn PluginPayload, + ) -> praxis_policy_core::executor::ErasedResultFields { + let mut ctx = PluginContext::new(); + invoke(plugin, hook, payload, &mut ctx) + .await + .expect("dispatch succeeds") + } + + #[tokio::test] + async fn a_ppe_identity_payload_reaches_the_worker_without_its_credential() { + if crate::testing::skip_without_python3( + "a_ppe_identity_payload_reaches_the_worker_without_its_credential", + ) { + return; + } + let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await; + set_mode(&dir, "allow"); + invoke_on(&plugin, "identity.resolve", &ppe_identity()).await; + + let task = last_task(&dir); + assert_eq!(task["hook_type"], "identity_resolve"); + assert_eq!(task["payload"]["headers"], serde_json::json!({})); + assert!(task.get("credential").is_none()); + let wire = serde_json::to_string(&task).unwrap(); + assert!(!wire.contains("PPE-IDENTITY-SECRET-TOKEN"), "{wire}"); + assert!(!wire.contains("PPE-COOKIE-SECRET"), "{wire}"); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_ppe_identity_payload_gives_a_declaring_plugin_the_gated_credential() { + if crate::testing::skip_without_python3( + "a_ppe_identity_payload_gives_a_declaring_plugin_the_gated_credential", + ) { + return; + } + let (dir, plugin) = + adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await; + set_mode(&dir, "identity_result"); + let fields = invoke_on(&plugin, "identity.resolve", &ppe_identity()).await; + + let task = last_task(&dir); + assert_eq!( + task["credential"]["inbound"]["token"], + "PPE-IDENTITY-SECRET-TOKEN" + ); + assert!( + !serde_json::to_string(&task["payload"]) + .unwrap() + .contains("PPE-IDENTITY-SECRET-TOKEN"), + "the token rides only the gated channel" + ); + + // The IdentityResult maps back onto the PPE payload PPE downcasts to. + let modified = fields + .modified_payload + .expect("the result is a modification"); + let identity = modified + .as_any() + .downcast_ref::() + .expect("identity.resolve must come back as PPE's IdentityPayload"); + assert_eq!( + identity.subject.as_ref().and_then(|s| s.id.as_deref()), + Some("alice") + ); + assert_eq!(identity.raw_token(), "PPE-IDENTITY-SECRET-TOKEN"); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_rejected_identity_result_becomes_a_deny() { + if crate::testing::skip_without_python3("a_rejected_identity_result_becomes_a_deny") { + return; + } + let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await; + set_mode(&dir, "identity_rejected"); + let fields = invoke_on(&plugin, "identity.resolve", &ppe_identity()).await; + assert!(!fields.continue_processing); + assert!(fields.modified_payload.is_none()); + assert_eq!(fields.violation.unwrap().reason, "token expired"); + + plugin.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn a_ppe_delegation_payload_round_trips_through_the_worker() { + if crate::testing::skip_without_python3( + "a_ppe_delegation_payload_round_trips_through_the_worker", + ) { + return; + } + use praxis_policy_core::delegation::DelegationPayload; + + // PPE's bearer token is the caller's inbound credential. + let (dir, plugin) = + adapter_plugin(&["token.delegate"], &["read_inbound_credentials"]).await; + set_mode(&dir, "delegation_result"); + let payload = DelegationPayload::new("PPE-CALLER-BEARER", "billing") + .with_target_audience("https://billing.example.com"); + let fields = invoke_on(&plugin, "token.delegate", &payload).await; + + let task = last_task(&dir); + assert_eq!(task["hook_type"], "token_delegate"); + assert_eq!(task["payload"]["target_name"], "billing"); + assert!(task["payload"].get("bearer_token").is_none()); + assert_eq!( + task["credential"]["delegated"]["token"], + "PPE-CALLER-BEARER" + ); + + let modified = fields + .modified_payload + .expect("the result is a modification"); + let delegation = modified + .as_any() + .downcast_ref::() + .expect("token.delegate must come back as PPE's DelegationPayload"); + let minted = delegation.delegated_token.as_ref().expect("a minted token"); + assert_eq!(minted.token.as_str(), "STUB-MINTED-TOKEN"); + assert_eq!(minted.audience, "https://billing.example.com"); + + plugin.shutdown().await.unwrap(); + } + #[test] fn the_respawn_budget_is_bounded_then_resets_with_time() { // Unit-level pin on the accounting, so the bound and the reset window diff --git a/crates/cpex-hosts-python/src/testing.rs b/crates/cpex-hosts-python/src/testing.rs index 19ae3fd7..05dc29aa 100644 --- a/crates/cpex-hosts-python/src/testing.rs +++ b/crates/cpex-hosts-python/src/testing.rs @@ -323,3 +323,59 @@ pub fn prebuild_venv( } Ok(()) } + +/// Plugin kind [`TempDirFactory`] registers under. Distinct from +/// [`crate::factory::KIND`] so registering it never shadows the real factory. +pub const TEMP_DIR_KIND: &str = "isolated_venv_temp_dir"; + +/// A factory that builds the real Python plugin against a scaffolded temp dir. +/// +/// `IsolatedVenvFactory` deliberately ignores a `plugin_dirs` key in the +/// `config:` block: the host always resolves `/plugins`, and that +/// must not be relaxed to suit a test. It leaves the engine-driven e2e tests no +/// config-only way to redirect the venv, and a run that built into the +/// repository's real `plugins/` would be both destructive and dependent on +/// developer-machine state. So this factory applies the builder-only overrides +/// (`with_plugin_dirs` / `with_worker_cwd`) and hands the engine the finished +/// plugin. Everything downstream is the real engine path. +pub struct TempDirFactory { + pub plugin_dir: PathBuf, + pub worker_cwd: PathBuf, +} + +impl praxis_policy_core::factory::PluginFactory for TempDirFactory { + fn create( + &self, + config: &praxis_policy_core::plugin::PluginConfig, + ) -> Result< + praxis_policy_core::factory::PluginInstance, + Box, + > { + use std::sync::Arc; + + use praxis_policy_core::registry::AnyHookHandler; + + use crate::plugin::{IsolatedPythonPlugin, PythonHookAdapter}; + + let plugin = Arc::new( + IsolatedPythonPlugin::from_config(config)? + .with_plugin_dirs(vec![self.plugin_dir.display().to_string()]) + .with_worker_cwd(self.worker_cwd.clone()), + ); + + let handlers = config + .hooks + .iter() + .map(|hook| -> (&'static str, Arc) { + // Same leak the production factory does; bounded by config size. + let leaked: &'static str = Box::leak(hook.clone().into_boxed_str()); + ( + leaked, + Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)), + ) + }) + .collect(); + + Ok(praxis_policy_core::factory::PluginInstance { plugin, handlers }) + } +} diff --git a/crates/cpex-hosts-python/src/venv.rs b/crates/cpex-hosts-python/src/venv.rs index 8d1cdbff..bb4b3c32 100644 --- a/crates/cpex-hosts-python/src/venv.rs +++ b/crates/cpex-hosts-python/src/venv.rs @@ -236,15 +236,15 @@ pub fn evaluate_cache( } // Present-and-different invalidates; absent is "no signal" and skipped. - if let Some(cached) = metadata.manifest_version.as_deref() { - if Some(cached) != expected_manifest_version { - return CacheVerdict::ManifestVersionChanged; - } + if let Some(cached) = metadata.manifest_version.as_deref() + && Some(cached) != expected_manifest_version + { + return CacheVerdict::ManifestVersionChanged; } - if let Some(cached) = metadata.manifest_hash.as_deref() { - if Some(cached) != expected_manifest_hash { - return CacheVerdict::ManifestHashChanged; - } + if let Some(cached) = metadata.manifest_hash.as_deref() + && Some(cached) != expected_manifest_hash + { + return CacheVerdict::ManifestHashChanged; } CacheVerdict::Valid @@ -1396,9 +1396,11 @@ mod tests { // an unreadable file must degrade to the previous rebuild behavior // rather than wedge startup. let dir = TempDir::new(); - assert!(VenvOwners::load(&dir.path().join("nope.json")) - .owners - .is_empty()); + assert!( + VenvOwners::load(&dir.path().join("nope.json")) + .owners + .is_empty() + ); let corrupt = dir.path().join("owners.json"); std::fs::write(&corrupt, "{ not json").unwrap(); diff --git a/crates/cpex-hosts-python/src/worker.rs b/crates/cpex-hosts-python/src/worker.rs index 0efeea13..d26e11c3 100644 --- a/crates/cpex-hosts-python/src/worker.rs +++ b/crates/cpex-hosts-python/src/worker.rs @@ -37,7 +37,7 @@ use std::sync::Arc; use serde_json::Value; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, Command}; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::{Mutex, mpsc, oneshot}; use crate::error::HostError; @@ -65,6 +65,13 @@ pub const FEATURE_CREDENTIAL: &str = "credential"; /// Worker feature name for delivering the task's `extensions` field to a hook. pub const FEATURE_EXTENSIONS: &str = "extensions"; +/// Worker feature name for serving PPE's `cmf.*` hooks natively, with the CMF +/// `MessagePayload` and the `cmf.*` hook names. +/// +/// A worker that does not claim it gets the legacy payload shim instead (see +/// `crate::legacy::cmf_shim`). No released worker claims it yet. +pub const FEATURE_CMF_HOOKS: &str = "cmf_hooks"; + /// What a worker reported it can do, from the spawn-time handshake. /// /// An empty feature set is a valid, meaningful answer: it is what an old worker @@ -247,6 +254,17 @@ pub struct WorkerClient { /// Per-invocation response timeout. timeout_secs: u64, + + /// What the spawn-time handshake reported, once it has run. + /// + /// Held on the client rather than the plugin so a respawned worker starts + /// with no report and has to be asked again: the replacement may come from + /// a rebuilt venv with a different `worker.py`. + reported: std::sync::OnceLock, + + /// Whether the one probe retry this worker gets has been spent. See + /// [`Self::claim_probe_retry`]. + probe_retry_spent: std::sync::atomic::AtomicBool, } /// Manual rather than derived: the outbound channel carries serialized task @@ -335,9 +353,31 @@ impl WorkerClient { child: Mutex::new(Some(child)), max_content_size, timeout_secs, + reported: std::sync::OnceLock::new(), + probe_retry_spent: std::sync::atomic::AtomicBool::new(false), }) } + /// Claim this worker's single retry of a failed capabilities probe. + /// + /// True exactly once per worker. A probe that timed out or hit a worker + /// still starting leaves no report, and with no report a `cmf.*` hook is + /// served through the legacy shim for the worker's whole life. One retry + /// on first use recovers from a transient failure; bounding it to one + /// keeps a probe that keeps failing from adding a round trip to every + /// call. + pub fn claim_probe_retry(&self) -> bool { + !self + .probe_retry_spent + .swap(true, std::sync::atomic::Ordering::SeqCst) + } + + /// The handshake report from the last successful [`Self::capabilities`] + /// call, or `None` if the worker was never asked. + pub fn reported_capabilities(&self) -> Option<&WorkerCapabilities> { + self.reported.get() + } + /// Build a `WorkerDied` error, appending recent stderr when there is any. /// /// The stderr tail is what turns "worker process died" into something @@ -432,7 +472,7 @@ impl WorkerClient { Ok(Err(_)) => { return Err(self .died("worker exited while the request was in flight") - .await) + .await); }, Err(_) => { self.pending.lock().await.remove(&request_id); @@ -472,7 +512,9 @@ impl WorkerClient { "worker does not implement the capabilities handshake; \ treating it as supporting no gated wire features" ); - return Ok(WorkerCapabilities::none()); + let none = WorkerCapabilities::none(); + let _ = self.reported.set(none.clone()); + return Ok(none); }, Err(e) => return Err(e), }; @@ -488,7 +530,7 @@ impl WorkerClient { }) .unwrap_or_default(); - Ok(WorkerCapabilities { + let reported = WorkerCapabilities { protocol_version: response .get("protocol_version") .and_then(Value::as_u64) @@ -498,7 +540,9 @@ impl WorkerClient { .get("cpex_version") .and_then(Value::as_str) .map(str::to_string), - }) + }; + let _ = self.reported.set(reported.clone()); + Ok(reported) } /// Ask the worker to exit, then wait out the grace window and kill it. @@ -822,7 +866,7 @@ pub fn resolve_worker_script(venv_path: &Path, relative: &str) -> Result String { + let plugin = |name: &str, hook: &str, caps: &str| { + format!( + r#" + - name: {name} + kind: {TEMP_DIR_KIND} + hooks: [{hook}] + capabilities: [{caps}] + version: "1.0.0" + config: + class_name: {FIXTURE_CLASS} + requirements_file: requirements.txt + timeout_secs: 300 +"# + ) + }; + format!( + r#" +plugins:{}{}{} +routes: + - tool: {TOOL} + authentication: + - py-identity + - py-identity-blind + authorization: + pre_invocation: + - "require(authenticated)" + - "delegate(py-delegator, target: billing-api, audience: billing-api)" + - "require(delegation.granted)" +"#, + plugin( + "py-identity", + "identity.resolve", + "read_inbound_credentials" + ), + plugin("py-identity-blind", "identity.resolve", ""), + plugin( + "py-delegator", + "token.delegate", + "read_inbound_credentials, write_delegated_tokens" + ), + ) +} + +/// Skip guard: `Some(source)` when the credential path is exercisable. +fn require_environment(test_name: &str) -> Option { + if skip_without_python3(test_name) { + return None; + } + let source = match python_source() { + Ok(source) => source, + Err(reason) => { + skip(test_name, &reason); + return None; + }, + }; + if !worker_consumes_credentials(&source) { + skip( + test_name, + &format!( + "the cpex source at {} has a worker.py that does not consume the `credential` \ + field", + source.display() + ), + ); + return None; + } + Some(source) +} + +/// Scaffold the fixture and pre-build its venv, shared by all three plugins. +fn setup(dir: &TempDir, source: &Path, test_name: &str) -> Option { + let plugin_dir = scaffold_plugin(dir, source, PACKAGE, "plugin", FIXTURE_PLUGIN); + match prebuild_venv(&plugin_dir, source, FIXTURE_CLASS, PACKAGE) { + Ok(()) => Some(plugin_dir), + Err(reason) => { + skip( + test_name, + &format!("could not prepare the plugin venv: {reason}"), + ); + None + }, + } +} + +/// An engine with APL installed and the YAML loaded and initialized. +async fn engine(dir: &TempDir, plugin_dir: &Path) -> Arc { + let engine = Arc::new(PolicyEngine::new(PolicyEngineConfig::default())); + let _visitor = register_apl(&engine, AplOptions::in_process()); + engine.register_factory( + TEMP_DIR_KIND, + Box::new(TempDirFactory { + plugin_dir: plugin_dir.to_path_buf(), + worker_cwd: dir.path().to_path_buf(), + }), + ); + engine + .load_config_yaml(&policy_yaml()) + .expect("the policy YAML loads"); + engine + .initialize() + .await + .expect("the cached venv is reused and every worker starts"); + engine +} + +/// The request's extensions: route meta only, as a host builds them. +fn request_extensions() -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(TOOL.into()), + ..Default::default() + })), + ..Default::default() + } +} + +/// PPE's identity payload for a request carrying `token`. +fn identity_payload(token: &str) -> IdentityPayload { + IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("authorization") + .with_headers(HashMap::from([ + ("authorization".into(), format!("Bearer {token}")), + ("cookie".into(), COOKIE.into()), + ("x-tenant".into(), "acme".into()), + ])) + .with_client_host("10.0.0.7") +} + +fn tool_call() -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-acceptance-1".into(), + name: TOOL.into(), + arguments: HashMap::from([("employee_id".into(), serde_json::json!("e-42"))]), + namespace: None, + }, + }], + ), + } +} + +fn marker(dir: &TempDir, plugin: &str) -> Option { + std::fs::read_to_string(dir.path().join(format!("verdict-{plugin}.txt"))).ok() +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`"] +async fn python_identity_and_delegation_plugins_serve_a_ppe_route() { + let test = "python_identity_and_delegation_plugins_serve_a_ppe_route"; + let Some(source) = require_environment(test) else { + return; + }; + let dir = TempDir::new(); + let Some(plugin_dir) = setup(&dir, &source, test) else { + return; + }; + let engine = engine(&dir, &plugin_dir).await; + + // 1. identity.resolve, with PPE's own payload. + let (identity_result, _bg) = engine + .invoke_by_name( + HOOK_IDENTITY_RESOLVE, + Box::new(identity_payload(SECRET)), + request_extensions(), + None, + ) + .await; + assert!( + identity_result.continue_processing, + "identity resolution must allow: {:?} / errors {:?}", + identity_result.violation, identity_result.errors + ); + assert!( + identity_result.errors.is_empty(), + "{:?}", + identity_result.errors + ); + let resolved = IdentityPayload::from_pipeline_result(&identity_result) + .expect("the Python result maps back onto PPE's IdentityPayload"); + let subject = resolved + .subject + .clone() + .expect("the plugin resolved a subject"); + assert_eq!(subject.id.as_deref(), Some("alice")); + assert!(subject.roles.contains("hr")); + + // The capability decides who sees credential material. The capable plugin + // gets the token on `raw_token` and the credential headers through the + // gated channel, with the worker scrubbing the token out of the header + // values. The plugin beside it gets neither token nor raw headers: the + // public header map is empty by default. + let capable = marker(&dir, "py-identity").expect("py-identity ran"); + assert!(capable.starts_with("GOT_TOKEN;"), "{capable}"); + assert!(capable.contains("token_in_headers=False"), "{capable}"); + assert!(capable.contains("cookie=True"), "{capable}"); + assert!(capable.contains("tenant=acme"), "{capable}"); + let blind = marker(&dir, "py-identity-blind").expect("py-identity-blind ran"); + assert!( + blind.starts_with("NO_TOKEN;"), + "a plugin without read_inbound_credentials must not see the token: {blind}" + ); + assert!(blind.contains("token_in_headers=False"), "{blind}"); + assert!( + blind.contains("cookie=False"), + "credential headers reach only a declaring plugin: {blind}" + ); + assert!(blind.contains("tenant=None"), "{blind}"); + + // 2. The host applies the identity, and carries the inbound token for the + // delegate step to exchange. + let mut extensions = resolved.apply_to_extensions(request_extensions()); + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(SECRET, "authorization", TokenKind::Opaque), + ); + extensions.raw_credentials = Some(Arc::new(raw)); + + // 3. The route's policy: authenticated, delegate, delegation granted. + let (result, _bg) = engine + .invoke_by_name( + "cmf.tool_pre_invoke", + Box::new(tool_call()), + extensions, + None, + ) + .await; + assert!( + result.continue_processing, + "the route must allow an authenticated caller with a granted delegation: {:?} / errors \ + {:?}", + result.violation, result.errors + ); + + let delegator = marker(&dir, "py-delegator").expect("the delegate step reached the plugin"); + assert!( + delegator.starts_with("GOT_TOKEN;"), + "the delegator declared read_inbound_credentials and must get the caller's token: \ + {delegator}" + ); + assert!(delegator.contains("audience=billing-api"), "{delegator}"); + + // The minted token, mapped onto PPE's DelegationPayload, is what PPE + // stashed for the route's upstream call. + let merged = result + .modified_extensions + .as_ref() + .expect("the route's delegate step changed the extensions"); + let raw = merged + .raw_credentials + .as_ref() + .expect("the delegate step stashed raw credentials"); + let minted = raw + .delegated_tokens + .values() + .find(|t| t.token.as_str() == MINTED) + .expect("the minted token is stashed for the route"); + assert_eq!(minted.audience, "billing-api"); + assert_eq!(minted.outbound_header, "Authorization"); + + engine.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`"] +async fn a_rejected_credential_denies_and_an_unauthenticated_route_call_is_refused() { + let test = "a_rejected_credential_denies_and_an_unauthenticated_route_call_is_refused"; + let Some(source) = require_environment(test) else { + return; + }; + let dir = TempDir::new(); + let Some(plugin_dir) = setup(&dir, &source, test) else { + return; + }; + let engine = engine(&dir, &plugin_dir).await; + + // A credential the plugin does not accept: its IdentityResult sets + // `rejected`, which reaches PPE as a deny. + let (identity_result, _bg) = engine + .invoke_by_name( + HOOK_IDENTITY_RESOLVE, + Box::new(identity_payload("ACCEPTANCE-SOME-OTHER-CREDENTIAL")), + request_extensions(), + None, + ) + .await; + assert!(!identity_result.continue_processing); + let violation = identity_result.violation.expect("the deny names its cause"); + assert_eq!(violation.code, "auth.rejected"); + assert_eq!(violation.reason, "unknown credential"); + assert!( + marker(&dir, "py-identity-blind").is_none(), + "the chain halts at the rejection" + ); + + // With no resolved identity the route's `require(authenticated)` denies + // before the delegate step runs. + let (result, _bg) = engine + .invoke_by_name( + "cmf.tool_pre_invoke", + Box::new(tool_call()), + request_extensions(), + None, + ) + .await; + assert!( + !result.continue_processing, + "an unauthenticated caller must be refused" + ); + assert!( + marker(&dir, "py-delegator").is_none(), + "no delegation is attempted for an unauthenticated caller" + ); + + engine.shutdown().await; +} diff --git a/crates/cpex-hosts-python/tests/config_e2e.rs b/crates/cpex-hosts-python/tests/config_e2e.rs index 5dad0434..2f0f8b71 100644 --- a/crates/cpex-hosts-python/tests/config_e2e.rs +++ b/crates/cpex-hosts-python/tests/config_e2e.rs @@ -3,22 +3,31 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Ted Habeck // -// End-to-end through the *manager*: load `plugins/config.yaml`, register the -// `isolated_venv` factory, and invoke hooks on cpex-test-plugin — -// `tool_pre_invoke`, plus the two credential-adjacent hooks `identity_resolve` -// and `token_delegate`. +// End-to-end through the *engine*: load `plugins/config.yaml`, register the +// `isolated_venv` factory, and invoke hooks on cpex-test-plugin: +// `cmf.tool_pre_invoke`, plus the two credential-adjacent hooks +// `identity.resolve` and `token.delegate`. +// +// # The installer config predates PPE +// +// `cpex plugin install` writes a top-level `plugin_dirs:` key and the +// unprefixed hook names. PPE rejects both, so the test migrates the document +// the way an operator must (see `migrate_installer_config`). The plugin still +// implements the legacy `tool_pre_invoke`; the 0.1.x worker claims no +// `cmf_hooks`, so the host serves `cmf.tool_pre_invoke` to it through the +// legacy payload shim. // // # What this covers that `isolated_venv_e2e.rs` does not // // The other e2e tests build an `IsolatedPythonPlugin` directly and call its // adapter, which skips two layers a real gateway goes through: YAML → factory -// → registry, and `PluginManager::invoke_by_name` → executor → adapter. This +// → registry, and `PolicyEngine::invoke_by_name` → executor → adapter. This // one drives both, against a plugin installed the way an operator installs one // (`cpex plugin install`, not a scaffolded fixture). So it is the test that // catches a config-shape or dispatch-wiring break that a direct adapter call // would not see. // -// `tool_pre_invoke` also carries populated `Extensions`, which makes this the +// `cmf.tool_pre_invoke` also carries populated `Extensions`, which makes this the // only test that drives the extensions channel against an installer-written // config — one that declares `capabilities: []`. `extensions_merge_e2e.rs` owns // the observation and merge behaviour with a 3-arg fixture and explicit @@ -33,17 +42,29 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use cpex_core::extensions::{ - AgentExtension, Extensions, HttpExtension, RequestExtension, SecurityExtension, -}; -use cpex_core::hooks::types::hook_names; -use cpex_core::manager::PluginManager; use cpex_hosts_python::factory::{IsolatedVenvFactory, KIND}; -use cpex_hosts_python::legacy::{ - IdentityResolvePayload, TokenDelegatePayload, ToolPreInvokePayload, -}; +use cpex_hosts_python::legacy::{IdentityResolvePayload, TokenDelegatePayload}; use cpex_hosts_python::plugin::IsolatedPythonPlugin; use cpex_hosts_python::testing::{skip, skip_without_python3}; +use praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use praxis_policy_core::delegation::HOOK_TOKEN_DELEGATE; +use praxis_policy_core::engine::{PolicyEngine, PolicyEngineConfig}; +use praxis_policy_core::extensions::{ + AgentExtension, Extensions, HttpExtension, RequestExtension, SecurityExtension, +}; +use praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE; + +/// The PPE hook the tool test dispatches on. +const TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; + +/// Marker `TestPlugin.tool_pre_invoke` writes: the legacy method the shim +/// reaches for `cmf.tool_pre_invoke`. +const TOOL_PRE_INVOKE_MARKER: &str = "tool_pre_invoke"; + +/// Marker files `TestPlugin` writes, named after its Python hook methods. The +/// host translates PPE's dispatched names to these on the wire. +const IDENTITY_RESOLVE_MARKER: &str = "identity_resolve"; +const TOKEN_DELEGATE_MARKER: &str = "token_delegate"; /// Plugin name in `plugins/config.yaml`. const PLUGIN_NAME: &str = "cpex-test-plugin"; @@ -77,6 +98,60 @@ fn repo_root() -> PathBuf { .to_path_buf() } +/// Rewrite an installer-written config into one PPE accepts. +/// +/// Drops the top-level `plugin_dirs:` key, which PPE rejects, renames the +/// unprefixed tool, identity and delegation hooks to PPE's names, and selects +/// `dispatch: hooks` when the document has no `engine_settings:`, since the +/// installer writes no `routes:` or `authorization:` block. These are the edits +/// an operator makes by hand when moving an installed Python plugin onto PPE. +fn migrate_installer_config(yaml: &str) -> String { + let mut doc: serde_yaml::Value = serde_yaml::from_str(yaml).expect("config.yaml is YAML"); + let root = doc.as_mapping_mut().expect("config.yaml is a mapping"); + root.remove("plugin_dirs"); + if !root.contains_key("engine_settings") { + let mut settings = serde_yaml::Mapping::new(); + settings.insert("dispatch".into(), "hooks".into()); + root.insert("engine_settings".into(), settings.into()); + } + if let Some(plugins) = root + .get_mut("plugins") + .and_then(serde_yaml::Value::as_sequence_mut) + { + for plugin in plugins { + let Some(hooks) = plugin + .get_mut("hooks") + .and_then(serde_yaml::Value::as_sequence_mut) + else { + continue; + }; + for hook in hooks { + let renamed = match hook.as_str() { + Some("tool_pre_invoke") => TOOL_PRE_INVOKE, + Some("identity_resolve") => HOOK_IDENTITY_RESOLVE, + Some("token_delegate") => HOOK_TOKEN_DELEGATE, + _ => continue, + }; + *hook = serde_yaml::Value::from(renamed); + } + } + } + serde_yaml::to_string(&doc).expect("the migrated config serializes") +} + +/// Build an engine from the migrated installer config. +fn load_installed_config(config_path: &Path) -> Arc { + let yaml = std::fs::read_to_string(config_path).expect("plugins/config.yaml is readable"); + let config = praxis_policy_core::config::parse_config(&migrate_installer_config(&yaml)) + .expect("the migrated plugins/config.yaml parses"); + let manager = Arc::new(PolicyEngine::new(PolicyEngineConfig::default())); + manager.register_factory(KIND, Box::new(IsolatedVenvFactory)); + manager + .load_config(config) + .expect("plugins/config.yaml loads with the isolated_venv factory registered"); + manager +} + /// Skip guard: `Some(config_path)` when the installed-plugin fixture is present. /// /// Every branch prints why it skipped — a silent no-op is indistinguishable @@ -151,7 +226,7 @@ fn assert_marker_written(marker: &Path, description: &str) { /// `errors` is checked first: a worker that failed to start or a hook the /// framework rejected surfaces there rather than as a deny, so checking the /// allow first would point the failure message at the wrong layer. -fn assert_allowed(result: &cpex_core::executor::PipelineResult, hook_name: &str) { +fn assert_allowed(result: &praxis_policy_core::executor::PipelineResult, hook_name: &str) { assert!( result.errors.is_empty(), "the pipeline recorded plugin errors on {hook_name}: {:?}", @@ -183,8 +258,9 @@ fn assert_allowed(result: &cpex_core::executor::PipelineResult, hook_name: &str) /// * `security` — gated per sub-field. The slot survives, `labels` does not. /// /// The `Authorization` header is a second, independent guard: `attach_extensions` -/// strips sensitive headers on the way out, so even a config that *did* declare -/// `read_headers` must not put this value on the wire. +/// withholds unvalidated raw headers on the way out, so even a config that *did* declare +/// `read_headers` must not put this value on the wire. Outbound raw header maps +/// are now empty by default. fn inbound_extensions() -> Extensions { let mut security = SecurityExtension::default(); security.add_label(INBOUND_LABEL); @@ -256,25 +332,21 @@ async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() { // The plugin's `tool_pre_invoke` writes `.tool_pre_invoke` at the worker's // cwd. Remove any stale copy so its existence proves *this* run executed // the hook body rather than a previous one. - let marker = clear_marker(&root, hook_names::TOOL_PRE_INVOKE); + let marker = clear_marker(&root, TOOL_PRE_INVOKE_MARKER); - // Loaded straight from disk with no patching: the whole point is that a - // config.yaml as written by `cpex plugin install` works as-is. - let manager = Arc::new(PluginManager::default()); - manager.register_factory(KIND, Box::new(IsolatedVenvFactory)); - manager - .load_config_file(&config_path) - .expect("plugins/config.yaml loads with the isolated_venv factory registered"); + // Loaded from disk with only the migration PPE requires, so a config.yaml + // written by `cpex plugin install` is otherwise used as-is. + let manager = load_installed_config(&config_path); // A config that loaded but registered nothing would make every assertion // below vacuous: `invoke_by_name` short-circuits to an allow when no plugin // is on the hook, so the "clean input is allowed" checks would pass with no // Python involved at all. assert!( - manager.has_hooks_for(hook_names::TOOL_PRE_INVOKE), + manager.has_hooks_for(TOOL_PRE_INVOKE), "no plugin registered on {} — check that config.yaml still declares \ kind: {KIND} and that hook", - hook_names::TOOL_PRE_INVOKE + TOOL_PRE_INVOKE ); assert!( manager.plugin_names().iter().any(|n| n == PLUGIN_NAME), @@ -287,15 +359,23 @@ async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() { .await .expect("the venv resolves and the worker starts"); - // `tool_pre_invoke` carries the native Pydantic shape the Python plugin - // expects — `name` plus `args` — not the generic wrapper. - let payload = ToolPreInvokePayload { - name: "test_tool".to_string(), - args: Some(HashMap::from([( - "query".to_string(), - serde_json::json!("hello world"), - )])), - headers: None, + // A CMF tool call. The shim turns it into the `name` plus `args` shape the + // plugin's legacy `tool_pre_invoke` expects. + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-config-e2e-1".to_string(), + name: "test_tool".to_string(), + arguments: HashMap::from([( + "query".to_string(), + serde_json::json!("hello world"), + )]), + namespace: None, + }, + }], + ), }; // Populated extensions rather than `Extensions::default()`. The default is @@ -307,17 +387,12 @@ async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() { let inbound = inbound_extensions(); let (result, _background) = manager - .invoke_by_name( - hook_names::TOOL_PRE_INVOKE, - Box::new(payload), - inbound.clone(), - None, - ) + .invoke_by_name(TOOL_PRE_INVOKE, Box::new(payload), inbound.clone(), None) .await; manager.shutdown().await; - assert_allowed(&result, hook_names::TOOL_PRE_INVOKE); + assert_allowed(&result, TOOL_PRE_INVOKE_MARKER); // The marker proves the plugin's hook body ran. Without it, a response // shaped like an allow is indistinguishable from the manager's @@ -329,7 +404,7 @@ async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() { // the worker's Pydantic models reject surfaces as a validation error in // `errors` and no marker — which is precisely the break a `default()` input // could never detect. - assert_marker_written(&marker, hook_names::TOOL_PRE_INVOKE); + assert_marker_written(&marker, TOOL_PRE_INVOKE_MARKER); // `modified_extensions` on an allowed pipeline is the *final* extensions // view, not a "something changed" signal — `PipelineResult::allowed_with` @@ -375,7 +450,7 @@ async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() { // has no way to report what arrived, and `extensions_merge_e2e.rs` owns the // 3-arg observation path with a purpose-built fixture. This keeps the // capability contract pinned against the real installer-written config. - let filtered = cpex_core::extensions::filter_extensions(&inbound, &Default::default()); + let filtered = praxis_policy_core::extensions::filter_extensions(&inbound, &Default::default()); assert!( filtered.agent.is_none(), "`agent` is capability-gated and this config declares none, so it must \ @@ -417,14 +492,14 @@ async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() { } /// The two credential-adjacent hooks dispatch through the same config → factory -/// → registry → executor → worker path as `tool_pre_invoke`. +/// → registry → executor → worker path as `cmf.tool_pre_invoke`. /// /// # Why these two are worth their own test /// -/// `identity_resolve` and `token_delegate` are the only hooks the host treats +/// `identity.resolve` and `token.delegate` are the only hooks the host treats /// specially: `is_credential_hook` singles them out, and their payloads carry /// redacting fields (`raw_token`, `bearer_token`) that no other hook has. That -/// makes them the two most likely to break in a way `tool_pre_invoke` cannot +/// makes them the two most likely to break in a way the tool hook cannot /// detect — a payload-kind mapping that falls through to `PayloadKind::Generic`, /// for instance, still round-trips as an allow, so only a marker file /// distinguishes a real dispatch from a silent no-op. The plugin writes two @@ -453,8 +528,8 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta // cwd at once — see the comment in the test above. std::env::set_current_dir(&root).expect("cd to the repo root"); - let identity_marker = clear_marker(&root, hook_names::IDENTITY_RESOLVE); - let delegate_marker = clear_marker(&root, hook_names::TOKEN_DELEGATE); + let identity_marker = clear_marker(&root, IDENTITY_RESOLVE_MARKER); + let delegate_marker = clear_marker(&root, TOKEN_DELEGATE_MARKER); // Each credential hook writes a *second* marker when its redacting field // arrives non-empty (`.identity_resolve_raw_token`, @@ -464,15 +539,11 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta let identity_field_marker = clear_marker(&root, IDENTITY_RAW_TOKEN_MARKER); let delegate_field_marker = clear_marker(&root, TOKEN_DELEGATE_BEARER_MARKER); - let manager = Arc::new(PluginManager::default()); - manager.register_factory(KIND, Box::new(IsolatedVenvFactory)); - manager - .load_config_file(&config_path) - .expect("plugins/config.yaml loads with the isolated_venv factory registered"); + let manager = load_installed_config(&config_path); // Without these, both invocations below short-circuit to an allow with no // Python involved, and every assertion that follows would be vacuous. - for hook in [hook_names::IDENTITY_RESOLVE, hook_names::TOKEN_DELEGATE] { + for hook in [HOOK_IDENTITY_RESOLVE, HOOK_TOKEN_DELEGATE] { assert!( manager.has_hooks_for(hook), "no plugin registered on {hook} — check that config.yaml still lists it under \ @@ -490,7 +561,7 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta .await .expect("the venv resolves and the worker starts"); - // `identity_resolve` mirrors Python's `IdentityPayload`. `raw_token` is a + // `identity.resolve` carries the payload mirroring Python's `IdentityPayload`. `raw_token` is a // `SecretStr` there and carries a placeholder on the wire — the plaintext // would ride the separate capability-gated `credential` object, which this // config declares no capability for. @@ -504,14 +575,14 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta let (identity_result, _background) = manager .invoke_by_name( - hook_names::IDENTITY_RESOLVE, + HOOK_IDENTITY_RESOLVE, Box::new(identity_payload), Extensions::default(), None, ) .await; - // `token_delegate` mirrors `DelegationPayload`. `target_type` and + // `token.delegate` carries the payload mirroring `DelegationPayload`. `target_type` and // `auth_enforced_by` are left to their defaults so the worker's Pydantic // model has to supply them — an omitted-field mismatch surfaces as a // validation error in `errors`, not as a deny. @@ -533,7 +604,7 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta let (delegate_result, _background) = manager .invoke_by_name( - hook_names::TOKEN_DELEGATE, + HOOK_TOKEN_DELEGATE, Box::new(delegate_payload), Extensions::default(), None, @@ -542,14 +613,14 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta manager.shutdown().await; - assert_allowed(&identity_result, hook_names::IDENTITY_RESOLVE); - assert_allowed(&delegate_result, hook_names::TOKEN_DELEGATE); + assert_allowed(&identity_result, HOOK_IDENTITY_RESOLVE); + assert_allowed(&delegate_result, HOOK_TOKEN_DELEGATE); // The markers are what separate a real dispatch from an allow-shaped no-op: // a hook that never reached the plugin still comes back as an allow, so // without these the assertions above would pass on a silent no-op. - assert_marker_written(&identity_marker, hook_names::IDENTITY_RESOLVE); - assert_marker_written(&delegate_marker, hook_names::TOKEN_DELEGATE); + assert_marker_written(&identity_marker, IDENTITY_RESOLVE_MARKER); + assert_marker_written(&delegate_marker, TOKEN_DELEGATE_MARKER); // And these prove the redacting fields survived the round trip as populated // `SecretStr`s. A payload-kind mapping that fell through to @@ -565,25 +636,23 @@ async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_insta ); } -/// An installer-generated config resolves `plugins` at the project root with no -/// patching, and neither `plugin_dirs` key steers it. +/// An installer-generated config resolves `plugins` at the project root once +/// migrated, and the per-plugin `plugin_dirs` key does not steer it. /// -/// Needs no venv and no python3, so unlike the test above it runs on every -/// `cargo test`. This is the regression guard for the gap that used to require a -/// shim here: a config shaped exactly as `cpex plugin install` writes it must -/// load *and* resolve to the right directory. +/// Needs no venv and no python3, so unlike the tests above it runs on every +/// `cargo test`. #[test] fn an_installer_generated_config_resolves_plugins_at_the_project_root() { // The shape `cpex plugin install` generates: plugin_dirs at the top level, // absent from the plugin's own config block. Plus a per-plugin - // `plugin_dirs` pointing somewhere bogus, to prove neither key wins. + // `plugin_dirs` pointing somewhere bogus, to prove it does not win. let yaml = r#" plugin_dirs: - /top/level/ignored plugins: - name: cpex-test-plugin kind: isolated_venv - hooks: [tool_pre_invoke] + hooks: [tool_pre_invoke, identity_resolve, token_delegate] version: 0.2.1 config: class_name: cpex_test_plugin.plugin.TestPlugin @@ -591,10 +660,22 @@ plugins: plugin_dirs: ["/per/plugin/ignored"] "#; - let config = cpex_core::config::parse_config(yaml).expect("valid config"); + // As written, PPE refuses the top-level key and names its replacement. + let err = praxis_policy_core::config::parse_config(yaml) + .expect_err("a top-level plugin_dirs key must fail the load"); + assert!(err.to_string().contains("plugin_dirs"), "{err}"); - // Both keys still parse — they are simply not the host's source. - assert_eq!(config.plugin_dirs, vec!["/top/level/ignored".to_string()]); + let config = praxis_policy_core::config::parse_config(&migrate_installer_config(yaml)) + .expect("the migrated config parses"); + assert_eq!( + config.plugins[0].hooks, + vec![ + TOOL_PRE_INVOKE.to_string(), + HOOK_IDENTITY_RESOLVE.to_string(), + HOOK_TOKEN_DELEGATE.to_string() + ], + "the migrated names are PPE hooks, so no host hook registration is needed" + ); let plugin = IsolatedPythonPlugin::from_config(&config.plugins[0]) .expect("an ignored plugin_dirs key must not fail the load"); @@ -607,6 +688,6 @@ plugins: assert_eq!( plugin.plugin_dirs(), [expected], - "the host must resolve /plugins, ignoring both config keys" + "the host must resolve /plugins, ignoring the per-plugin key" ); } diff --git a/crates/cpex-hosts-python/tests/credential_e2e.rs b/crates/cpex-hosts-python/tests/credential_e2e.rs index 64a67162..745cd785 100644 --- a/crates/cpex-hosts-python/tests/credential_e2e.rs +++ b/crates/cpex-hosts-python/tests/credential_e2e.rs @@ -33,20 +33,21 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use cpex_core::context::PluginContext; -use cpex_core::extensions::raw_credentials::{ - RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, -}; -use cpex_core::extensions::Extensions; -use cpex_core::plugin::{Plugin, PluginConfig}; -use cpex_core::registry::AnyHookHandler; use cpex_hosts_python::factory::KIND; use cpex_hosts_python::legacy::IdentityResolvePayload; use cpex_hosts_python::plugin::{IsolatedPythonPlugin, PythonHookAdapter}; use cpex_hosts_python::testing::{ - prebuild_venv, python_source, scaffold_plugin, skip, skip_without_python3, - worker_consumes_credentials, TempDir, + TempDir, prebuild_venv, python_source, scaffold_plugin, skip, skip_without_python3, + worker_consumes_credentials, +}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; +use praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; +use praxis_policy_core::registry::AnyHookHandler; /// The plaintext under test. Distinctive so a leak is unambiguous in any /// captured output. @@ -162,7 +163,7 @@ fn plugin_config(capabilities: &[&str]) -> PluginConfig { PluginConfig { name: "identity-reader".into(), kind: KIND.into(), - hooks: vec!["identity_resolve".into()], + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(), version: Some("1.0.0".into()), config: Some(serde_json::json!({ @@ -188,7 +189,8 @@ fn plugin_for( ) } -/// Run one identity_resolve invocation. +/// Run one `identity.resolve` invocation. The fixture implements the worker's +/// `identity_resolve` method; the host translates the name on the wire. /// /// Returns the plugin's recorded verdict plus the worker's retained stderr, /// captured before shutdown so the credential-leak assertions have something to @@ -206,7 +208,7 @@ async fn run_identity_hook( .expect("the cached venv is reused and the worker starts"); let payload = IdentityResolvePayload::default(); - let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve"); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), HOOK_IDENTITY_RESOLVE); let mut ctx = PluginContext::new(); adapter .invoke(&payload, &extensions_with_token(), &mut ctx) @@ -370,7 +372,7 @@ async fn a_declared_capability_that_cannot_be_honored_fails_closed_end_to_end() plugin.initialize().await.expect("initializes"); let payload = IdentityResolvePayload::default(); - let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve"); + let adapter = PythonHookAdapter::new(Arc::clone(&plugin), HOOK_IDENTITY_RESOLVE); let mut ctx = PluginContext::new(); // No raw-credentials extension at all. let result = adapter diff --git a/crates/cpex-hosts-python/tests/extensions_merge_e2e.rs b/crates/cpex-hosts-python/tests/extensions_merge_e2e.rs index ba5db8fb..da8c1ab0 100644 --- a/crates/cpex-hosts-python/tests/extensions_merge_e2e.rs +++ b/crates/cpex-hosts-python/tests/extensions_merge_e2e.rs @@ -6,12 +6,12 @@ // End-to-end: extensions delivered to, and returned from, an out-of-process // Python plugin — with the mutability tiers enforced by the real executor. // -// # Why these go through `PluginManager` rather than the adapter alone +// # Why these go through `PolicyEngine` rather than the adapter alone // // The unit tests in `src/extensions.rs` cover the wire shape and every *deny* // outcome. They cannot cover the accept outcomes, and that is by design: // `http`, `security`, and `delegation` writes are gated by a `WriteToken`, and -// `WriteToken::new()` is `pub(crate)` to `cpex-core`. This crate cannot mint +// `WriteToken::new()` is `pub(crate)` to `praxis-policy-core`. This crate cannot mint // one even in a test — which is exactly the property that makes the gate // trustworthy. // @@ -59,32 +59,30 @@ // today) would be a dead hook rather than a narrower view. See // `IsolatedPythonPlugin::worker_capabilities`. // -// # Still open, on the Python side: every header is dropped +// # Outbound raw headers and the remaining HTTP shape mismatch // // This framework's `HttpExtension` models a single `headers` field, while the // host sends `request_headers`/`response_headers` per the wire contract. -// Pydantic drops unknown keys silently, so *every* header the host sends is -// discarded with no error — confirmed directly: `model_validate` on -// `{"request_headers": {...}}` yields `headers={}`. +// The host now sends empty raw header maps by default. Pydantic also drops +// those split fields silently; a future approved outbound header or a +// worker-returned header edit still needs a shape mapping. // -// That is a gap in the Python model, not in this host, so it is not fixed here. // The fixture reads whichever attribute exists and records `HTTP_SLOT_EMPTY`, -// which keeps the mismatch visible in the verdict instead of letting it read as -// a clean run. The security assertion still holds either way, and holds for the -// stronger reason: the label proves the channel is live, so an empty header map -// is evidence of the model gap rather than of a dead channel. +// which now records the deliberate empty outbound map. The label assertion +// separately proves the extensions channel is live. use std::path::{Path, PathBuf}; use std::sync::Arc; -use cpex_core::extensions::{Extensions, HttpExtension, SecurityExtension}; -use cpex_core::plugin::PluginConfig; use cpex_hosts_python::extensions::{EXTENSIONS_FIELD, MODIFIED_EXTENSIONS_FIELD}; use cpex_hosts_python::factory::KIND; use cpex_hosts_python::testing::{ - prebuild_venv, python_source, scaffold_plugin, skip, skip_without_python3, - worker_delivers_extensions, TempDir, + TEMP_DIR_KIND, TempDir, TempDirFactory, prebuild_venv, python_source, scaffold_plugin, skip, + skip_without_python3, worker_delivers_extensions, }; +use praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use praxis_policy_core::extensions::{Extensions, HttpExtension, SecurityExtension}; +use praxis_policy_core::plugin::PluginConfig; /// Fully-qualified class name of the fixture plugin. const FIXTURE_CLASS: &str = "ext_pkg.extensions_plugin.ExtensionsAwarePlugin"; @@ -157,9 +155,7 @@ class ExtensionsAwarePlugin(Plugin): if "x-request-id" in lowered: verdict += ";GOT_SAFE_HEADER" elif slot_empty: - # The host sent a safe header; nothing arrived. Recorded so the - # host/worker field-name mismatch stays visible in the verdict - # rather than looking like a clean run. + # Raw headers are withheld by default, even on a live channel. verdict += ";HTTP_SLOT_EMPTY" marker = os.path.join(os.getcwd(), "extensions_verdict.txt") @@ -219,8 +215,8 @@ fn setup(dir: &TempDir, source: &Path, test_name: &str) -> Option { } } -/// Inbound extensions: a label to read, a safe header, and three that must be -/// stripped before they reach another process. +/// Inbound extensions: a label to read and raw headers that must all be +/// withheld before they reach another process. fn inbound_extensions() -> Extensions { let mut security = SecurityExtension::default(); security.add_label(INBOUND_LABEL); @@ -238,26 +234,45 @@ fn inbound_extensions() -> Extensions { } } -/// The legacy `tool_pre_invoke` hook, as a type the manager can dispatch. +/// The PPE hook every test here dispatches on. /// -/// `invoke_named` is generic over `HookTypeDef` to recover the payload type for -/// the typed handler path. The Python host registers type-erased handlers, so -/// only the payload type matters here — it must match what the adapter -/// serializes, which is this crate's `legacy::ToolPreInvokePayload`. -struct ToolPreInvoke; - -impl cpex_core::hooks::trait_def::HookTypeDef for ToolPreInvoke { - type Payload = cpex_hosts_python::legacy::ToolPreInvokePayload; - type Result = - cpex_core::hooks::trait_def::PluginResult; - const NAME: &'static str = "tool_pre_invoke"; +/// The fixture implements only the legacy `tool_pre_invoke`. A 0.1.x worker +/// claims no `cmf_hooks`, so the host serves this hook to it through the legacy +/// payload shim. +const HOOK: &str = "cmf.tool_pre_invoke"; + +/// A CMF message holding one tool call, which the shim turns into the legacy +/// `ToolPreInvokePayload` the fixture reads. +fn tool_call_message() -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-ext-1".into(), + name: "search".into(), + arguments: Default::default(), + namespace: None, + }, + }], + ), + } +} + +/// Engine settings that fire each plugin at the hooks it declares, so the test +/// needs no `routes:` or `authorization:` block. +fn hooks_dispatch() -> praxis_policy_core::config::EngineSettings { + praxis_policy_core::config::EngineSettings { + dispatch: praxis_policy_core::config::DispatchMode::Hooks, + ..Default::default() + } } fn plugin_config(capabilities: &[&str]) -> PluginConfig { PluginConfig { name: "extensions-aware".into(), kind: KIND.into(), - hooks: vec!["tool_pre_invoke".into()], + hooks: vec![HOOK.into()], capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(), version: Some("1.0.0".into()), config: Some(serde_json::json!({ @@ -299,17 +314,9 @@ fn the_inbound_task_carries_visible_slots_without_sensitive_headers() { let headers = wire["http"]["request_headers"] .as_object() .expect("request_headers is an object"); - for name in headers.keys() { - let lowered = name.to_ascii_lowercase(); - assert!( - !matches!(lowered.as_str(), "authorization" | "cookie" | "x-api-key"), - "sensitive header '{name}' must not cross the process boundary" - ); - } - assert_eq!( - headers.get("X-Request-Id").and_then(|v| v.as_str()), - Some("req-e2e-1"), - "a non-sensitive header still travels" + assert!( + headers.is_empty(), + "no unvalidated raw header may cross the process boundary: {headers:?}" ); let serialized = serde_json::to_string(&task).expect("the task serializes"); @@ -422,70 +429,10 @@ async fn an_appended_label_is_dropped_without_the_capability() { ); } -/// A factory that builds the real Python plugin against a scaffolded temp dir. +/// Register the Python plugin on `cmf.tool_pre_invoke`, run one pipeline pass, +/// and return the fixture's recorded verdict plus the merged extensions. /// -/// `IsolatedVenvFactory` deliberately ignores a `plugin_dirs` key in the -/// `config:` block — the host always resolves `/plugins`, and the -/// factory warns and drops the key rather than appearing to honour it. That is -/// the right production behaviour, and it is covered by the factory's own unit -/// tests, so it must not be relaxed to suit a test. -/// -/// But it leaves these tests no config-only way to redirect the venv, and a run -/// that built into the repository's real `plugins/` would be both destructive -/// and dependent on developer-machine state. The temp-dir overrides are -/// builder-only (`with_plugin_dirs` / `with_worker_cwd`) by design, so this -/// factory applies them and hands the manager the finished plugin. Everything -/// downstream — capability filtering, write-token minting, the copy-on-write -/// merge — is the real manager path, which is what these tests exist to cover. -mod temp_dir_factory { - use std::path::PathBuf; - use std::sync::Arc; - - use cpex_core::error::PluginError; - use cpex_core::factory::{PluginFactory, PluginInstance}; - use cpex_core::plugin::PluginConfig; - use cpex_hosts_python::plugin::{IsolatedPythonPlugin, PythonHookAdapter}; - - /// Distinct from `factory::KIND` so registering this never shadows the real - /// factory for any other test in the binary. - pub const KIND: &str = "isolated_venv_temp_dir"; - - pub struct TempDirFactory { - pub plugin_dir: PathBuf, - pub worker_cwd: PathBuf, - } - - impl PluginFactory for TempDirFactory { - fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new( - IsolatedPythonPlugin::from_config(config)? - .with_plugin_dirs(vec![self.plugin_dir.display().to_string()]) - .with_worker_cwd(self.worker_cwd.clone()), - ); - - let handlers: Vec<_> = config - .hooks - .iter() - .map( - |hook| -> (&'static str, Arc) { - let leaked: &'static str = Box::leak(hook.clone().into_boxed_str()); - ( - leaked, - Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)), - ) - }, - ) - .collect(); - - Ok(PluginInstance { plugin, handlers }) - } - } -} - -/// Register the Python plugin on `tool_pre_invoke`, run one pipeline pass, and -/// return the fixture's recorded verdict plus the merged extensions. -/// -/// Going through `PluginManager` is the point: it is what filters extensions per +/// Going through `PolicyEngine` is the point: it is what filters extensions per /// capability, mints the write tokens, and runs the copy-on-write merge that /// validates the plugin's return. async fn run_pipeline( @@ -493,25 +440,26 @@ async fn run_pipeline( plugin_dir: &Path, capabilities: &[&str], ) -> (String, Extensions) { - use cpex_core::config::CpexConfig; - use cpex_core::manager::{ManagerConfig, PluginManager}; + use praxis_policy_core::config::PolicyConfig; + use praxis_policy_core::engine::{PolicyEngine, PolicyEngineConfig}; let mut config = plugin_config(capabilities); - // The temp-dir redirection rides on the factory (see `temp_dir_factory`), + // The temp-dir redirection rides on the factory (see `TempDirFactory`), // not on a config key the host ignores. - config.kind = temp_dir_factory::KIND.into(); + config.kind = TEMP_DIR_KIND.into(); - let manager = PluginManager::new(ManagerConfig::default()); + let manager = PolicyEngine::new(PolicyEngineConfig::default()); manager.register_factory( - temp_dir_factory::KIND, - Box::new(temp_dir_factory::TempDirFactory { + TEMP_DIR_KIND, + Box::new(TempDirFactory { plugin_dir: plugin_dir.to_path_buf(), worker_cwd: dir.path().to_path_buf(), }), ); manager - .load_config(CpexConfig { + .load_config(PolicyConfig { plugins: vec![config], + engine_settings: hooks_dispatch(), ..Default::default() }) .expect("the config loads"); @@ -522,13 +470,13 @@ async fn run_pipeline( let inbound = inbound_extensions(); let (result, _bg) = manager - .invoke_named::( - "tool_pre_invoke", - cpex_hosts_python::legacy::ToolPreInvokePayload::default(), - inbound.clone(), - None, - ) + .invoke_by_name(HOOK, Box::new(tool_call_message()), inbound.clone(), None) .await; + assert!( + result.errors.is_empty(), + "the pipeline recorded plugin errors: {:?}", + result.errors + ); manager.shutdown().await; @@ -557,14 +505,14 @@ mod fake_worker { use std::sync::Arc; use async_trait::async_trait; - use cpex_core::context::PluginContext; - use cpex_core::error::PluginError; - use cpex_core::executor::ErasedResultFields; - use cpex_core::extensions::Extensions; - use cpex_core::factory::{PluginFactory, PluginInstance}; - use cpex_core::hooks::PluginPayload; - use cpex_core::plugin::{Plugin, PluginConfig}; - use cpex_core::registry::AnyHookHandler; + use praxis_policy_core::context::PluginContext; + use praxis_policy_core::error::PluginError; + use praxis_policy_core::executor::ErasedResultFields; + use praxis_policy_core::extensions::Extensions; + use praxis_policy_core::factory::{PluginFactory, PluginInstance}; + use praxis_policy_core::hooks::PluginPayload; + use praxis_policy_core::plugin::{Plugin, PluginConfig}; + use praxis_policy_core::registry::AnyHookHandler; use serde_json::Value; /// Kind name for the stand-in factory. @@ -611,7 +559,9 @@ mod fake_worker { } fn hook_type_name(&self) -> &'static str { - "tool_pre_invoke" + praxis_policy_core::hooks::lookup_hook_metadata(super::HOOK) + .and_then(|meta| meta.family) + .unwrap_or(super::HOOK) } } @@ -632,7 +582,7 @@ mod fake_worker { cfg: config.clone(), }), handlers: vec![( - "tool_pre_invoke", + super::HOOK, Arc::new(FakeWorkerHandler { response }) as Arc, )], }) @@ -648,33 +598,34 @@ async fn merge_through_executor( capabilities: &[&str], response: serde_json::Value, ) -> Option { - use cpex_core::config::CpexConfig; - use cpex_core::manager::{ManagerConfig, PluginManager}; + use praxis_policy_core::config::PolicyConfig; + use praxis_policy_core::engine::{PolicyEngine, PolicyEngineConfig}; let config = PluginConfig { name: "fake-worker".into(), kind: fake_worker::KIND.into(), - hooks: vec!["tool_pre_invoke".into()], + hooks: vec![HOOK.into()], capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(), version: Some("1.0.0".into()), config: Some(serde_json::json!({"response": response})), ..Default::default() }; - let manager = PluginManager::new(ManagerConfig::default()); + let manager = PolicyEngine::new(PolicyEngineConfig::default()); manager.register_factory(fake_worker::KIND, Box::new(fake_worker::FakeWorkerFactory)); manager - .load_config(CpexConfig { + .load_config(PolicyConfig { plugins: vec![config], + engine_settings: hooks_dispatch(), ..Default::default() }) .expect("the config loads"); manager.initialize().await.expect("the plugin initializes"); let (result, _bg) = manager - .invoke_named::( - "tool_pre_invoke", - cpex_hosts_python::legacy::ToolPreInvokePayload::default(), + .invoke_by_name( + HOOK, + Box::new(tool_call_message()), inbound_extensions(), None, ) diff --git a/crates/cpex-hosts-python/tests/isolated_venv_e2e.rs b/crates/cpex-hosts-python/tests/isolated_venv_e2e.rs index 6d7a8cb5..13da6488 100644 --- a/crates/cpex-hosts-python/tests/isolated_venv_e2e.rs +++ b/crates/cpex-hosts-python/tests/isolated_venv_e2e.rs @@ -32,16 +32,19 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use cpex_core::context::PluginContext; -use cpex_core::extensions::Extensions; -use cpex_core::plugin::{Plugin, PluginConfig}; -use cpex_core::registry::AnyHookHandler; use cpex_hosts_python::factory::KIND; use cpex_hosts_python::legacy::ToolPreInvokePayload; use cpex_hosts_python::plugin::{IsolatedPythonPlugin, PythonHookAdapter}; use cpex_hosts_python::testing::{ - prebuild_venv, python_source, scaffold_plugin, skip, skip_without_python3, TempDir, + TEMP_DIR_KIND, TempDir, TempDirFactory, prebuild_venv, python_source, scaffold_plugin, skip, + skip_without_python3, }; +use praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::extensions::Extensions; +use praxis_policy_core::hooks::payload::PluginPayload; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; +use praxis_policy_core::registry::AnyHookHandler; /// A Python plugin that marks the filesystem when its hook body runs, so the /// test can prove the plugin *executed* rather than merely that a response @@ -54,7 +57,13 @@ from cpex.framework.models import PluginResult, PluginViolation class MarkerPlugin(Plugin): - """Writes a marker file, then allows — or denies when args say to.""" + """Writes a marker file, then allows, denies, or rewrites args. + + A 0.1.x-style plugin: it implements only the legacy `tool_pre_invoke` with + the typed `ToolPreInvokePayload`. Its config block's `mode` picks the + behavior: `gate` (the default) denies `q == "DENY-ME"`, and `rewrite` + replaces `q == "REWRITE-ME"` with `"rewritten"`. + """ def __init__(self, config): super().__init__(config) @@ -64,7 +73,15 @@ class MarkerPlugin(Plugin): f.write(f"ran:{payload.name}") args = payload.args or {} - if args.get("q") == "DENY-ME": + # Per plugin instance, so a chain of two can be told apart. + with open(os.path.join(os.getcwd(), f"saw-{self.name}.txt"), "w") as f: + f.write(f"{payload.name}:{args.get('q')}") + + mode = (self._config.config or {}).get("mode", "gate") + if mode == "rewrite" and args.get("q") == "REWRITE-ME": + rewritten = payload.model_copy(update={"args": {**args, "q": "rewritten"}}) + return PluginResult(continue_processing=True, modified_payload=rewritten) + if mode == "gate" and args.get("q") == "DENY-ME": return PluginResult( continue_processing=False, violation=PluginViolation( @@ -155,15 +172,16 @@ fn plugin_for( async fn invoke( plugin: &Arc, hook: &'static str, - payload: &ToolPreInvokePayload, -) -> Result { + payload: &dyn PluginPayload, +) -> Result { let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook); let mut ctx = PluginContext::new(); let erased = adapter .invoke(payload, &Extensions::default(), &mut ctx) .await .map_err(|e| e.to_string())?; - Ok(cpex_core::executor::extract_erased(erased).expect("adapter returns ErasedResultFields")) + Ok(praxis_policy_core::executor::extract_erased(erased) + .expect("adapter returns ErasedResultFields")) } #[tokio::test(flavor = "multi_thread")] @@ -272,36 +290,169 @@ async fn a_real_plugin_deny_surfaces_as_a_deny_result() { plugin.shutdown().await.unwrap(); } +/// A CMF tool call for `search` with one argument, between two empty parts, so +/// an engine round trip can show those parts survive the shim. +fn cmf_tool_call(q: &str) -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ + ContentPart::Text { + text: String::new(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-e2e-1".into(), + name: "search".into(), + arguments: std::collections::HashMap::from([( + "q".into(), + serde_json::json!(q), + )]), + namespace: None, + }, + }, + ContentPart::Thinking { + text: String::new(), + }, + ], + ), + } +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`"] -async fn a_cmf_hook_is_rejected_cleanly_by_a_framework_that_has_no_cmf_hooks() { - // The plan asks for a CMF round-trip here. It is not achievable against - // this framework version, and the reason is worth pinning rather than - // omitting: the Python hook registry knows only the legacy names — - // `get_result_type("cmf.tool_pre_invoke")` returns None, and - // `json_to_payload` raises "No payload defined for hook cmf.tool_pre_invoke". - // No `cmf.*` hook is registered anywhere in `cpex/framework/hooks/`. - // - // So the CMF path cannot be proven end to end until the Python side - // registers those hooks. What *is* provable, and asserted here, is that - // this host does its half correctly: it serializes the CMF message payload - // (not the generic wrapper — verified in the conversion and adapter unit - // tests), and the worker's rejection surfaces as a clean plugin error - // rather than a hang, a crash, or a silent allow. The last of those is the - // one that would matter in production: a CMF hook that quietly returned - // "allow" would bypass policy. - let Some(source) = - require_environment("a_cmf_hook_is_rejected_cleanly_by_a_framework_that_has_no_cmf_hooks") - else { +async fn legacy_plugins_on_cmf_tool_pre_invoke_rewrite_and_deny_through_the_engine() { + // The 0.1.x worker registers no `cmf.*` hook and the fixture implements only + // the legacy `tool_pre_invoke`. Through the legacy payload shim both still + // run on PPE's `cmf.tool_pre_invoke`, in a real two-plugin pipeline: a + // rewriter (priority 10) modifies args, and a gate (priority 20) sees the + // rewritten args and denies on its own trigger. + let test = "legacy_plugins_on_cmf_tool_pre_invoke_rewrite_and_deny_through_the_engine"; + let Some(source) = require_environment(test) else { return; }; + let dir = TempDir::new(); + let Some(plugin_dir) = setup(&dir, &source, test) else { + return; + }; + + use praxis_policy_core::config::{DispatchMode, EngineSettings, PolicyConfig}; + use praxis_policy_core::engine::{PolicyEngine, PolicyEngineConfig}; + + let entry = |name: &str, mode: &str, priority: i32| { + let mut config = plugin_config(&["cmf.tool_pre_invoke"], &[]); + config.name = name.into(); + config.kind = TEMP_DIR_KIND.into(); + config.priority = priority; + config.config.as_mut().unwrap()["mode"] = serde_json::json!(mode); + config + }; + + let engine = PolicyEngine::new(PolicyEngineConfig::default()); + engine.register_factory( + TEMP_DIR_KIND, + Box::new(TempDirFactory { + plugin_dir: plugin_dir.clone(), + worker_cwd: dir.path().to_path_buf(), + }), + ); + engine + .load_config(PolicyConfig { + plugins: vec![entry("rewriter", "rewrite", 10), entry("gate", "gate", 20)], + engine_settings: EngineSettings { + dispatch: DispatchMode::Hooks, + ..Default::default() + }, + ..Default::default() + }) + .expect("cmf.tool_pre_invoke is a PPE hook, so no host hook registration is needed"); + engine + .initialize() + .await + .expect("the cached venv is reused and both workers start"); + + // --- the rewrite maps back onto the CMF message -------------------------- + let original = cmf_tool_call("REWRITE-ME"); + let (result, _bg) = engine + .invoke_by_name( + "cmf.tool_pre_invoke", + Box::new(original.clone()), + Extensions::default(), + None, + ) + .await; + assert!( + result.errors.is_empty(), + "plugin errors: {:?}", + result.errors + ); + assert!( + result.continue_processing, + "violation: {:?}", + result.violation + ); + + let modified = result + .modified_payload + .as_ref() + .expect("the rewriter's modification survives the pipeline"); + let message = modified + .as_any() + .downcast_ref::() + .expect("a cmf hook's modification comes back as a MessagePayload"); + let mut expected = serde_json::to_value(&original).unwrap(); + expected["message"]["content"][1]["content"]["arguments"]["q"] = serde_json::json!("rewritten"); + assert_eq!( + serde_json::to_value(message).unwrap(), + expected, + "only the tool call's args change; every other part and field survives" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("saw-gate.txt")).unwrap(), + "search:rewritten", + "the gate, behind the rewriter, must see the rewritten args" + ); + + // --- a legacy deny passes through unchanged ------------------------------ + let (result, _bg) = engine + .invoke_by_name( + "cmf.tool_pre_invoke", + Box::new(cmf_tool_call("DENY-ME")), + Extensions::default(), + None, + ) + .await; + assert!( + result.errors.is_empty(), + "plugin errors: {:?}", + result.errors + ); + assert!(!result.continue_processing, "the gate must deny"); + let violation = result + .violation + .as_ref() + .expect("a deny carries its violation"); + assert_eq!(violation.code, "FIXTURE_DENY"); + assert_eq!(violation.reason, "denied by fixture"); + assert_eq!(violation.details["arg"], "q"); + engine.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`"] +async fn a_cmf_message_the_shim_cannot_represent_fails_closed() { + // A `cmf.tool_pre_invoke` message with no tool call has no legacy + // `ToolPreInvokePayload` to become. The host must refuse it with an error + // naming the hook and the plugin, not send the worker something it would + // misread and not report an allow: a silent allow on a CMF hook would + // bypass policy. + let test = "a_cmf_message_the_shim_cannot_represent_fails_closed"; + let Some(source) = require_environment(test) else { + return; + }; let dir = TempDir::new(); - let Some(plugin_dir) = setup( - &dir, - &source, - "a_cmf_hook_is_rejected_cleanly_by_a_framework_that_has_no_cmf_hooks", - ) else { + let Some(plugin_dir) = setup(&dir, &source, test) else { return; }; let config = plugin_config(&["cmf.tool_pre_invoke"], &[]); @@ -309,31 +460,48 @@ async fn a_cmf_hook_is_rejected_cleanly_by_a_framework_that_has_no_cmf_hooks() { let plugin = plugin_for(&config, &plugin_dir, dir.path()); plugin.initialize().await.expect("initializes"); - let payload = cpex_core::cmf::MessagePayload { - message: cpex_core::cmf::Message::text(cpex_core::cmf::Role::User, "what is the weather?"), + let payload = MessagePayload { + message: Message::text(Role::User, "what is the weather?"), }; let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "cmf.tool_pre_invoke"); let mut ctx = PluginContext::new(); - let result = adapter + let Err(err) = adapter .invoke(&payload, &Extensions::default(), &mut ctx) - .await; - - let Err(err) = result else { - panic!( - "a framework with no cmf hooks must not report success — a silent allow on a CMF hook \ - would bypass policy" - ); + .await + else { + panic!("an unrepresentable cmf message must not report success"); }; let message = err.to_string(); assert!( - message.contains("cmf.tool_pre_invoke") || message.to_lowercase().contains("payload"), - "the error should explain that the hook is unknown to the framework: {message}" + message.contains("cmf.tool_pre_invoke"), + "names the hook: {message}" + ); + assert!( + message.contains("fixture-marker"), + "names the plugin: {message}" + ); + assert!( + message.contains("no tool_call content part"), + "says why: {message}" + ); + assert!( + !dir.path().join("marker.txt").exists(), + "the plugin must not have run" + ); + + // The worker is untouched and still serves a representable message. + let fields = invoke(&plugin, "cmf.tool_pre_invoke", &cmf_tool_call("clean")) + .await + .expect("a tool call message round-trips"); + assert!(fields.continue_processing); + assert_eq!( + std::fs::read_to_string(dir.path().join("marker.txt")).unwrap(), + "ran:search" ); - // Still healthy: one rejected hook must not poison the worker. assert!( plugin.shutdown().await.is_ok(), - "the worker should survive a rejected hook and shut down cleanly" + "the worker should shut down cleanly" ); } diff --git a/crates/cpex-orchestration/Cargo.toml b/crates/cpex-orchestration/Cargo.toml deleted file mode 100644 index cd137193..00000000 --- a/crates/cpex-orchestration/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -# Location: ./crates/cpex-orchestration/Cargo.toml -# Copyright 2026 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# Shared async orchestration primitives. -# -# This crate is a leaf utility — no internal workspace dependencies. -# Provides the JoinSet-based concurrent runner that both: -# * `cpex-core::executor::run_concurrent_phase` — fans out concurrent -# plugins for one hook -# * `apl-core::evaluator` — fans out the effects in an APL -# `parallel:` block -# share. -# -# Speaks generic `Future` + an `is_deny` predicate, not any -# domain types. Each caller adapts its concepts to this surface. - -[package] -name = "cpex-orchestration" -description = "Async concurrency primitives shared by the CPEX runtime" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[lib] - -[dependencies] -tokio = { workspace = true, features = ["rt", "time", "macros"] } -futures = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } - -[lints] -workspace = true diff --git a/crates/cpex-orchestration/src/lib.rs b/crates/cpex-orchestration/src/lib.rs deleted file mode 100644 index 108647b9..00000000 --- a/crates/cpex-orchestration/src/lib.rs +++ /dev/null @@ -1,442 +0,0 @@ -// Location: ./crates/cpex-orchestration/src/lib.rs -// Copyright 2026 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Async concurrency primitives shared by the CPEX runtime. -// -// Two callers today, both running "N async branches concurrently with -// optional short-circuit on first deny": -// -// * `cpex-core::executor::run_concurrent_phase` — fans out concurrent -// plugins for one hook event -// * `apl-core::evaluator::dispatch_parallel` — fans out the effects -// inside an APL `parallel:` block -// -// Both want the same mechanics — `tokio::task::JoinSet` keyed by task -// id, react-to-results-as-they-arrive, optional `abort_all` on first -// deny, per-branch timeout. Without a shared primitive, both would -// reinvent the pattern (slightly differently) and drift. -// -// This crate exposes a single generic function `run_branches`. It -// speaks `Future` + an `is_deny` predicate — no domain -// concepts. Each caller adapts its types (HookEntry, EffectOutcome, -// Decision, …) at the boundary. - -#![deny(rust_2018_idioms)] - -use std::collections::HashMap; -use std::time::Duration; - -use futures::future::BoxFuture; -use tokio::task::{Id, JoinSet}; -use tokio::time::timeout; - -/// Configuration knobs for [`run_branches`]. -#[derive(Debug, Clone, Copy)] -pub struct BranchConfig { - /// Maximum time each individual branch is allowed to run before - /// being recorded as `BranchOutcome::TimedOut`. `None` disables - /// the per-branch timeout (relies on cancellation from - /// `short_circuit_on_deny` and the outer caller). - pub timeout_per_branch: Option, - - /// When `true`, abort the remaining branches as soon as the first - /// branch returns a result satisfying the `is_deny` predicate. - /// Aborted branches are returned as `BranchOutcome::Aborted`. - pub short_circuit_on_deny: bool, -} - -impl Default for BranchConfig { - fn default() -> Self { - Self { - timeout_per_branch: None, - short_circuit_on_deny: true, - } - } -} - -/// What happened to one branch in [`run_branches`]. -/// -/// Branches always return results in the **input order** (index 0 -/// first, even if it physically finished last). Callers that care -/// about wall-clock completion order need to add their own -/// timestamping inside the branch future. -#[derive(Debug)] -pub enum BranchOutcome { - /// Branch ran to completion within its timeout and produced `T`. - Completed(T), - /// Branch exceeded its `timeout_per_branch`. Callers typically - /// treat this as a deny / failure depending on policy. - TimedOut, - /// Branch was cancelled before completion because an earlier - /// branch tripped `short_circuit_on_deny`. Distinguishable from - /// `TimedOut` so audit/logging can tell whether the framework - /// or the caller's own time budget killed the task. - Aborted, - /// Branch's spawned task panicked. Carries the panic payload's - /// `Display` representation for logging — the typed payload is - /// dropped (JoinError doesn't preserve it across boxing). - Panicked(String), -} - -impl BranchOutcome { - /// Get a reference to the completed value if the branch succeeded. - /// `None` for timeouts, aborts, and panics. - pub fn completed(&self) -> Option<&T> { - match self { - BranchOutcome::Completed(v) => Some(v), - _ => None, - } - } - - /// Consume the outcome, returning the completed value if any. - pub fn into_completed(self) -> Option { - match self { - BranchOutcome::Completed(v) => Some(v), - _ => None, - } - } -} - -/// Run `branches` concurrently, returning one [`BranchOutcome`] per -/// branch in **input order**. -/// -/// # Behaviour -/// -/// * Each branch is spawned onto the current tokio runtime via -/// `JoinSet::spawn`. The runtime must be `rt-multi-thread` for the -/// branches to actually run in parallel; single-threaded runtimes -/// will run them concurrently (interleaved) but on one OS thread. -/// * If `config.short_circuit_on_deny` is set, the moment any branch -/// completes with a result satisfying `is_deny`, all remaining -/// branches are aborted via `JoinSet::abort_all`. They surface as -/// `BranchOutcome::Aborted`. -/// * If `config.timeout_per_branch` is set, each branch is wrapped in -/// `tokio::time::timeout`. Timeouts surface as `BranchOutcome::TimedOut`. -/// * Panics inside a branch are caught (tokio's `JoinSet` returns -/// them via `JoinError::is_panic`) and surfaced as -/// `BranchOutcome::Panicked` rather than re-panicking — the -/// intent is that one misbehaving branch shouldn't take down the -/// whole orchestrator. -/// -/// # Cost notes -/// -/// * `tokio::task::spawn` has ~1 µs overhead per spawn — fine for -/// the workload sizes this is designed for (typically 2-20 -/// branches). If you need 1000+ branches, profile first. -/// * Each branch's future is `Send + 'static` (it's spawned onto a -/// task) — captured state must satisfy those bounds. Most callers -/// handle this by cloning state per branch before constructing the -/// future. -pub async fn run_branches( - branches: Vec, - config: BranchConfig, - is_deny: P, -) -> Vec> -where - T: Send + 'static, - F: std::future::Future + Send + 'static, - P: Fn(&T) -> bool + Send + Sync, -{ - let n = branches.len(); - if n == 0 { - return Vec::new(); - } - - // Spawn each branch onto the JoinSet. The spawn handle's `Id` is - // captured into `id_to_idx` so a panicked task — which surfaces as - // a `JoinError` carrying only its `Id`, not the return value — can - // still be mapped back to its input index. - let mut set: JoinSet<(usize, BranchOutcome)> = JoinSet::new(); - let mut id_to_idx: HashMap = HashMap::with_capacity(n); - for (idx, fut) in branches.into_iter().enumerate() { - let to = config.timeout_per_branch; - let handle = set.spawn(async move { - let result = match to { - None => Ok(fut.await), - Some(d) => timeout(d, fut).await, - }; - let outcome = match result { - Ok(v) => BranchOutcome::Completed(v), - Err(_) => BranchOutcome::TimedOut, - }; - (idx, outcome) - }); - id_to_idx.insert(handle.id(), idx); - } - - // Collect outcomes into a position-indexed Vec so the return order - // matches input order regardless of physical completion order. - // `None` slots get filled as branches finish; remaining `None`s - // after all completions get replaced with `Aborted` (only - // possible when short-circuit fired). - let mut slots: Vec>> = (0..n).map(|_| None).collect(); - let mut aborted = false; - - while let Some(joined) = set.join_next_with_id().await { - match joined { - Ok((_id, (idx, outcome))) => { - let halts = matches!(&outcome, BranchOutcome::Completed(v) if is_deny(v)); - slots[idx] = Some(outcome); - if halts && config.short_circuit_on_deny && !aborted { - set.abort_all(); - aborted = true; - // Don't break — we still need to drain whatever - // tasks already completed before we asked for the - // abort, so their outcomes land in their slots - // (vs. being silently lost). The drain loop - // continues until JoinSet is empty. - } - }, - Err(e) => { - // A task either panicked or was cancelled by - // `abort_all`. JoinError exposes the task `Id`, which - // we look up in `id_to_idx` to recover the original - // input index. Panicked branches land in their own - // slot; cancelled ones get left as `None` and filled - // with `Aborted` post-loop. - if e.is_panic() { - let payload = format!("{:?}", e); - if let Some(&idx) = id_to_idx.get(&e.id()) { - slots[idx] = Some(BranchOutcome::Panicked(payload)); - } - } - }, - } - } - - // Anything still unset was aborted by `short_circuit_on_deny`. - slots - .into_iter() - .map(|s| s.unwrap_or(BranchOutcome::Aborted)) - .collect() -} - -// Implementation note on the generic signature: -// -// `P` is the closure type for `is_deny`. We declare it as a generic -// type parameter rather than `impl Fn(...)` so the function works -// uniformly across async runtimes and callers that need to use -// boxed predicates (`Box`) for runtime polymorphism. -// -// The `BoxFuture` import isn't strictly needed for the public API -// but is re-exported below for callers that want to build -// homogeneous branch vectors out of differently-typed futures (the -// common case in apl-core's `Effect::Parallel` dispatch, where each -// effect's future has a unique inferred type). - -/// Convenience alias re-exported from `futures` for callers building -/// type-erased branch vectors. `apl-core`'s `Effect::Parallel` -/// dispatch uses this because the per-effect futures have different -/// inferred types and need erasure to fit in a single `Vec`. -pub type ErasedBranch = BoxFuture<'static, T>; - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - - fn no_deny(_: &T) -> bool { - false - } - - #[tokio::test(flavor = "multi_thread")] - async fn all_complete_in_input_order() { - // Branches finish in REVERSE wall-clock order — sleep more - // for earlier indices. The output Vec must still be in input - // order: branch[0] → first slot, branch[2] → last slot. - let branches: Vec<_> = (0usize..3) - .map(|idx| { - Box::pin(async move { - let delay = Duration::from_millis(30 - 10 * idx as u64); - tokio::time::sleep(delay).await; - idx - }) as BoxFuture<'static, usize> - }) - .collect(); - - let out = run_branches( - branches, - BranchConfig { - timeout_per_branch: None, - short_circuit_on_deny: false, - }, - no_deny::, - ) - .await; - - assert_eq!(out.len(), 3); - for (i, outcome) in out.into_iter().enumerate() { - match outcome { - BranchOutcome::Completed(v) => assert_eq!(v, i, "input order preserved"), - other => panic!("expected Completed({}), got {:?}", i, other), - } - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn timeout_marks_branch_as_timed_out() { - let branches: Vec<_> = vec![ - Box::pin(async { - tokio::time::sleep(Duration::from_secs(60)).await; - "should not see this" - }) as BoxFuture<'static, &str>, - Box::pin(async { "quick" }) as BoxFuture<'static, &str>, - ]; - - let out = run_branches( - branches, - BranchConfig { - timeout_per_branch: Some(Duration::from_millis(50)), - short_circuit_on_deny: false, - }, - no_deny::<&str>, - ) - .await; - - assert!(matches!(out[0], BranchOutcome::TimedOut)); - assert!(matches!(out[1], BranchOutcome::Completed("quick"))); - } - - #[tokio::test(flavor = "multi_thread")] - async fn short_circuit_on_deny_aborts_remaining() { - // Branch 0 returns Deny quickly; branches 1 and 2 are slow. - // With short_circuit, the slow ones should be Aborted. - let counter = Arc::new(AtomicUsize::new(0)); - let c0 = counter.clone(); - let c1 = counter.clone(); - let c2 = counter.clone(); - - let branches: Vec> = vec![ - Box::pin(async move { - tokio::time::sleep(Duration::from_millis(5)).await; - c0.fetch_add(1, Ordering::SeqCst); - true // deny - }), - Box::pin(async move { - tokio::time::sleep(Duration::from_secs(60)).await; - c1.fetch_add(1, Ordering::SeqCst); - false - }), - Box::pin(async move { - tokio::time::sleep(Duration::from_secs(60)).await; - c2.fetch_add(1, Ordering::SeqCst); - false - }), - ]; - - let out = run_branches( - branches, - BranchConfig { - timeout_per_branch: None, - short_circuit_on_deny: true, - }, - |v: &bool| *v, - ) - .await; - - assert!(matches!(out[0], BranchOutcome::Completed(true))); - assert!(matches!(out[1], BranchOutcome::Aborted)); - assert!(matches!(out[2], BranchOutcome::Aborted)); - // Only the first branch should have incremented; the slow - // ones were aborted before they got past their sleeps. - assert_eq!(counter.load(Ordering::SeqCst), 1); - } - - #[tokio::test(flavor = "multi_thread")] - async fn short_circuit_disabled_keeps_all_running() { - // Same shape as above but with short_circuit OFF — all three - // should run to completion despite branch 0 denying. - let branches: Vec> = vec![ - Box::pin(async { - tokio::time::sleep(Duration::from_millis(5)).await; - true - }), - Box::pin(async { - tokio::time::sleep(Duration::from_millis(20)).await; - false - }), - Box::pin(async { - tokio::time::sleep(Duration::from_millis(20)).await; - false - }), - ]; - - let out = run_branches( - branches, - BranchConfig { - timeout_per_branch: None, - short_circuit_on_deny: false, - }, - |v: &bool| *v, - ) - .await; - - assert!(matches!(out[0], BranchOutcome::Completed(true))); - assert!(matches!(out[1], BranchOutcome::Completed(false))); - assert!(matches!(out[2], BranchOutcome::Completed(false))); - } - - #[tokio::test] - async fn empty_input_returns_empty_output() { - let out: Vec> = run_branches( - Vec::>::new(), - BranchConfig::default(), - no_deny::<()>, - ) - .await; - assert!(out.is_empty()); - } - - #[tokio::test(flavor = "multi_thread")] - async fn panic_inside_branch_does_not_take_down_orchestrator() { - let branches: Vec> = - vec![Box::pin(async { panic!("boom") }), Box::pin(async { 42 })]; - let out = run_branches( - branches, - BranchConfig { - timeout_per_branch: None, - short_circuit_on_deny: false, - }, - no_deny::, - ) - .await; - // Branch 1 must complete despite branch 0's panic. - assert!(out - .iter() - .any(|o| matches!(o, BranchOutcome::Completed(42)))); - assert!(out.iter().any(|o| matches!(o, BranchOutcome::Panicked(_)))); - } - - #[tokio::test(flavor = "multi_thread")] - async fn panic_lands_in_correct_input_slot() { - // Branch 1 panics; branches 0 and 2 succeed. The panicked - // outcome must land at index 1, not "the first empty slot." - // This guards executor consumers that key per-entry - // `on_error` policy off the branch index. - let branches: Vec> = vec![ - Box::pin(async { 10 }), - Box::pin(async { panic!("middle branch boom") }), - Box::pin(async { 30 }), - ]; - let out = run_branches( - branches, - BranchConfig { - timeout_per_branch: None, - short_circuit_on_deny: false, - }, - no_deny::, - ) - .await; - assert_eq!(out.len(), 3); - assert!(matches!(out[0], BranchOutcome::Completed(10))); - assert!( - matches!(out[1], BranchOutcome::Panicked(_)), - "panic must land at index 1, got {:?}", - out[1] - ); - assert!(matches!(out[2], BranchOutcome::Completed(30))); - } -} diff --git a/crates/cpex-sdk/Cargo.toml b/crates/cpex-sdk/Cargo.toml deleted file mode 100644 index 6248631c..00000000 --- a/crates/cpex-sdk/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -# Location: ./crates/cpex-sdk/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# CPEX SDK — lean crate for plugin authors. -# Re-exports the Plugin trait and payload/result types from cpex-core -# without pulling in the PluginManager, hosts, or FFI. - -[package] -name = "cpex-sdk" -description = "CPEX plugin author SDK — Plugin trait, payloads, and result types." -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -rust-version.workspace = true - -[dependencies] -cpex-core = { workspace = true } - -[lints] -workspace = true diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs deleted file mode 100644 index e4ef19f4..00000000 --- a/crates/cpex-sdk/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -// Location: ./crates/cpex-sdk/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// CPEX SDK — lean crate for plugin authors. -// -// Re-exports the Plugin trait and supporting types from cpex-core. -// Plugin authors depend on this crate instead of the full runtime, -// keeping their dependency tree minimal. This is also the crate -// that WASM plugins compile against. - -// Plugin lifecycle -pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; - -// Hook system -pub use cpex_core::hooks::{Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult}; - -// Context -pub use cpex_core::context::PluginContext; - -// Errors -pub use cpex_core::error::{PluginError, PluginViolation}; - -// Re-export the define_hook! macro -pub use cpex_core::define_hook; - -// CMF types -pub use cpex_core::cmf::{ - // Content parts and domain objects - AudioSource, - // Enums - Channel, - // Message and payload - CmfHook, - ContentPart, - ContentType, - DocumentSource, - ImageSource, - Message, - MessagePayload, - PromptRequest, - PromptResult, - Resource, - ResourceReference, - ResourceType, - Role, - ToolCall, - ToolResult, - VideoSource, -}; diff --git a/crates/cpex/Cargo.toml b/crates/cpex/Cargo.toml index 150b15de..7d28ca43 100644 --- a/crates/cpex/Cargo.toml +++ b/crates/cpex/Cargo.toml @@ -3,23 +3,20 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Fred Araujo # -# cpex — host facade. +# cpex: host facade over the praxis-policy engine (PPE). # -# One dependency instead of a dozen: re-exports the host runtime -# (PluginManager, AplOptions, register_apl). The bundled plugins, PDPs, and -# session stores live in `cpex-builtins` and are pulled in only when the -# `builtins` / `full` feature (or a granular plugin feature) is enabled — so -# the default `cpex = "0.2"` is the engine alone, no plugins compiled. +# One dependency instead of a dozen: re-exports PPE's public surface +# (PolicyEngine, AplOptions, register_apl, prelude, the builtin factories and +# the bundled hyper HTTP transport). Every PPE builtin is compiled in, so # -# cpex = { version = "0.2.3", features = ["builtins"] } # default builtin set -# cpex = { version = "0.2.3", features = ["jwt", "cedar"] } # a minimal subset +# cpex = "0.3" # -# then `cpex::install_builtins(&mgr)` registers every enabled factory and +# then `cpex::install_builtins(&engine)` registers every bundled factory and # installs the APL config visitor in one call. [package] name = "cpex" -description = "CPEX host facade — re-exports the runtime and (optionally) the feature-gated builtin extensions." +description = "CPEX host facade: re-exports the praxis-policy engine with its bundled builtins." version.workspace = true edition.workspace = true license.workspace = true @@ -33,54 +30,31 @@ rust-version.workspace = true # is workspace-root-relative; cargo bundles it into the published .crate). readme.workspace = true -# Build docs.rs with every extension enabled. The default build is engine-only -# (`default = []`), which omits cpex-builtins entirely — so without this the -# builtins re-exports and `install_builtins` are absent and the intra-doc link -# to `cpex-builtins` does not resolve on docs.rs. +# Build docs.rs with the optional Python host too, so its re-exports render. [package.metadata.docs.rs] all-features = true [features] -# Engine only by default: no builtin plugins are compiled in. Opt into the -# bundled extensions with `builtins` (the common in-process set), `full` -# (everything, incl. the Valkey session store), or a granular plugin -# feature. Each enables the optional `cpex-builtins` dependency and forwards -# to its matching cpex-builtins feature. default = [] -builtins = ["cpex-builtins/default"] -full = ["cpex-builtins/full"] - -# Granular passthroughs — each pulls exactly one builtin via cpex-builtins. -jwt = ["cpex-builtins/identity-jwt"] -oauth = ["cpex-builtins/delegator-oauth"] -pii = ["cpex-builtins/pii-scanner"] -audit = ["cpex-builtins/audit-logger"] -elicitation-ciba = ["cpex-builtins/elicitation-ciba"] -cedar = ["cpex-builtins/cedar-direct"] -cel = ["cpex-builtins/cel"] -valkey = ["cpex-builtins/valkey"] - -# Out-of-process host for existing Python CPEX plugins. Not part of -# `builtins` / `full`: those are the in-process bundled extension set, while -# this pulls a host that spawns a `worker.py` subprocess per plugin. +# Out-of-process host for existing Python CPEX plugins. Spawns a `worker.py` +# subprocess per plugin, so it stays opt-in. python-host = ["dep:cpex-hosts-python"] [dependencies] -# Host runtime — always present, this is the point of the facade. -cpex-core = { workspace = true } -apl-core = { workspace = true } -apl-cmf = { workspace = true } -apl-cpex = { workspace = true } +# The engine, with every stable builtin and the bundled hyper transport. +praxis-policy = { workspace = true, features = ["builtins", "http-hyper"] } -# Python plugin host — runs each Python plugin out-of-process, in its own -# virtualenv, from the Rust PluginManager. Present only with `python-host`. +# Python plugin host. Runs each Python plugin out-of-process, in its own +# virtualenv, from the Rust PolicyEngine. Present only with `python-host`. cpex-hosts-python = { workspace = true, optional = true, default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } -# Bundled extension set — present only when a builtins feature is enabled. -# `default-features = false` so the granular plugin features compose; an -# enabling feature turns on exactly the cpex-builtins features it names. -cpex-builtins = { workspace = true, optional = true, default-features = false } +[dev-dependencies] +tokio = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["fmt"] } [lints] workspace = true diff --git a/crates/cpex/src/host.rs b/crates/cpex/src/host.rs new file mode 100644 index 00000000..2b5bcd3f --- /dev/null +++ b/crates/cpex/src/host.rs @@ -0,0 +1,377 @@ +// Location: ./crates/cpex/src/host.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo + +//! Host wiring shared by the CPEX language bindings. +//! +//! The C FFI and the Python wheel install the same defaults on an engine, +//! report secret failures with the same kind strings, refuse the same +//! removed hook names and the same caller-supplied identity fields. Those +//! rules live here once, so a Go host and a Python host see the same +//! behavior. Each binding keeps only its own wire shapes. + +use std::sync::Arc; + +use praxis_policy::praxis_policy_core::http::HttpTransport; +use praxis_policy::praxis_policy_core::identity::IdentityPayload; +use praxis_policy::praxis_policy_core::secrets::RefreshReport; +use praxis_policy::{ + HyperTransport, PolicyEngine, SecretError, install_builtins, registry_with_vault, +}; + +/// Hook names removed in 0.3, each paired with the name that replaced it. +/// +/// A config naming one of these fails to load. An invoke call naming one +/// would reach no handler and come back allowed, so the bindings refuse +/// these names at invoke time too. Use [`removed_hook_replacement`] to +/// look one up. +pub const REMOVED_HOOKS: &[(&str, &str)] = &[ + ("tool_pre_invoke", "cmf.tool_pre_invoke"), + ("tool_post_invoke", "cmf.tool_post_invoke"), + ("prompt_pre_fetch", "cmf.prompt_pre_invoke"), + ("cmf.prompt_pre_fetch", "cmf.prompt_pre_invoke"), + ("prompt_post_fetch", "cmf.prompt_post_invoke"), + ("cmf.prompt_post_fetch", "cmf.prompt_post_invoke"), + ("resource_pre_fetch", "cmf.resource_pre_fetch"), + ("resource_post_fetch", "cmf.resource_post_fetch"), + ("identity_resolve", "identity.resolve"), + ("token_delegate", "token.delegate"), + ("cmf.http_request", "http.request"), + ("cmf.http_response", "http.response"), +]; + +/// The replacement for a hook name removed in 0.3, or `None` when +/// `hook_name` is not one of the [`REMOVED_HOOKS`]. +pub fn removed_hook_replacement(hook_name: &str) -> Option<&'static str> { + REMOVED_HOOKS + .iter() + .find(|(removed, _)| *removed == hook_name) + .map(|(_, replacement)| *replacement) +} + +/// Install everything a binding host gets by default on `engine`. +/// +/// What it installs: +/// - every bundled builtin factory and the APL config visitor, through +/// [`install_builtins`] +/// - one [`HyperTransport`] for builtins that perform HTTP +/// - the secret providers `env`, `file` and `vault`, with Vault using +/// that same transport instance +/// +/// With `allow_private_destinations` the transport may reach private, +/// loopback and link-local addresses; without it such requests are +/// refused and builtins report their egress-denied codes. This is a host +/// decision; no policy document can turn it on. Enabling it emits a +/// startup warning through the host's tracing subscriber, or stderr when +/// the host has no subscriber. +/// +/// Call it once per engine, on the same `Arc` that later loads the +/// config, so the APL visitor's weak reference upgrades during the load. +/// `install_builtins` appends a new APL visitor on every call, so a second +/// call would walk policy sections twice. The transport and the secret +/// providers are set-once: if the host already installed either, the +/// existing one is kept and the engine logs a warning. +pub fn install_host_defaults(engine: &Arc, allow_private_destinations: bool) { + install_builtins(engine); + + let mut hyper = HyperTransport::new(); + if allow_private_destinations { + warn_private_destinations(); + hyper = hyper.with_allow_private_destinations(); + } + let transport: Arc = Arc::new(hyper); + // Both setters are set-once and log their own warning when a + // transport or registry is already present. + let _ = engine.set_http_transport(Arc::clone(&transport)); + let _ = engine.set_secret_providers(registry_with_vault(transport)); +} + +const PRIVATE_DESTINATIONS_WARNING: &str = "private destinations are enabled for all outbound builtins and Vault; \ + loopback, RFC 1918, link-local (including cloud metadata) and CGNAT \ + addresses are reachable; use only with trusted policies and networks"; + +/// Report a host's global private-destination opt-in at startup. +/// +/// Rust hosts constructing a transport directly can call this too. +/// Hosts without a tracing subscriber receive the warning on stderr. +pub fn warn_private_destinations() { + log_private_destinations(std::io::stderr()); +} + +fn log_private_destinations(mut fallback: impl std::io::Write) { + tracing::warn!("{PRIVATE_DESTINATIONS_WARNING}"); + if tracing::dispatcher::get_default(|dispatch| { + dispatch.is::() + }) { + let _ = writeln!(fallback, "WARN cpex: {PRIVATE_DESTINATIONS_WARNING}"); + } +} + +/// The failure class of a secret error, as the bindings report it. +/// +/// One of `config`, `reference`, `not_found`, `malformed`, `backend`, or +/// `other` for a class this build does not know. The strings are part of +/// the binding wire contract: Go and Python hosts read the same values. +pub fn secret_error_kind(err: &SecretError) -> &'static str { + match err { + SecretError::Config { .. } => "config", + SecretError::Reference { .. } => "reference", + SecretError::NotFound { .. } => "not_found", + SecretError::Malformed { .. } => "malformed", + SecretError::Backend { .. } => "backend", + _ => "other", + } +} + +/// The resolver output fields set on an inbound `identity.resolve` +/// payload, by their wire names. +/// +/// Only `source`, `source_header`, `headers`, `client_host` and +/// `client_port` are inputs. `subject`, `client`, `caller_workload`, +/// `delegation`, `raw_credentials`, `resolved_at` and `raw_claims` are what +/// resolvers fill in. A caller that pre-fills them could forge the +/// resolved identity, so the bindings refuse a payload for which this +/// returns anything. +pub fn resolved_identity_fields(payload: &IdentityPayload) -> Vec<&'static str> { + let mut set = Vec::new(); + if payload.subject.is_some() { + set.push("subject"); + } + if payload.client.is_some() { + set.push("client"); + } + if payload.caller_workload.is_some() { + set.push("caller_workload"); + } + if payload.delegation.is_some() { + set.push("delegation"); + } + if payload.raw_credentials.is_some() { + set.push("raw_credentials"); + } + if payload.resolved_at.is_some() { + set.push("resolved_at"); + } + if !payload.raw_claims.is_empty() { + set.push("raw_claims"); + } + set +} + +/// 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( + payload: &IdentityPayload, +) -> Result { + let mut value = serde_json::to_value(payload)?; + if let serde_json::Value::Object(map) = &mut value { + map.remove("headers"); + } + Ok(value) +} + +/// Refresh report shared by the C and Python bindings. +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct RefreshSummary { + pub updated: Vec, + pub unchanged: u64, + pub failed: Vec, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct RefreshFailure { + pub secret: String, + pub provider: String, + pub kind: String, + pub message: String, +} + +impl From for RefreshSummary { + fn from(report: RefreshReport) -> Self { + Self { + updated: report.updated, + unchanged: report.unchanged as u64, + failed: report + .failed + .into_iter() + .map(|failure| RefreshFailure { + kind: secret_error_kind(&failure.source).to_owned(), + message: failure.source.to_string(), + secret: failure.secret, + provider: failure.provider, + }) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_destination_warning_reaches_hosts_without_a_subscriber() { + let mut output = Vec::new(); + tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::new(), || { + log_private_destinations(&mut output); + }); + assert_eq!( + String::from_utf8(output).unwrap(), + format!("WARN cpex: {PRIVATE_DESTINATIONS_WARNING}\n") + ); + } + + #[test] + fn private_destination_opt_in_warns_about_its_scope() { + #[derive(Clone)] + struct Log(std::sync::Arc>>); + impl std::io::Write for Log { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let log = Log(Default::default()); + let writer = log.clone(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .without_time() + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, || { + install_host_defaults(&Arc::new(PolicyEngine::default()), false); + assert!(log.0.lock().unwrap().is_empty()); + install_host_defaults(&Arc::new(PolicyEngine::default()), true); + }); + let text = String::from_utf8(log.0.lock().unwrap().clone()).unwrap(); + for expected in [ + "WARN", + "all outbound builtins and Vault", + "cloud metadata", + "CGNAT", + ] { + assert!(text.contains(expected), "{text}"); + } + } + + #[test] + fn removed_hooks_map_to_their_replacements() { + assert_eq!( + removed_hook_replacement("tool_pre_invoke"), + Some("cmf.tool_pre_invoke") + ); + assert_eq!( + removed_hook_replacement("cmf.prompt_pre_fetch"), + Some("cmf.prompt_pre_invoke") + ); + assert_eq!( + removed_hook_replacement("identity_resolve"), + Some("identity.resolve") + ); + assert_eq!(removed_hook_replacement("cmf.tool_pre_invoke"), None); + assert_eq!(removed_hook_replacement("custom.hook"), None); + } + + /// Every replacement is a hook the engine knows, and no removed name is. + #[test] + fn removed_hooks_are_unknown_and_replacements_known() { + use praxis_policy::praxis_policy_core::hooks::lookup_hook_metadata; + for (removed, replacement) in REMOVED_HOOKS { + assert!( + lookup_hook_metadata(removed).is_none(), + "{removed} must not be a registered hook" + ); + assert!( + lookup_hook_metadata(replacement).is_some(), + "{replacement} must be a registered hook" + ); + } + } + + #[test] + fn install_host_defaults_installs_the_transport_and_secret_providers() { + let engine = Arc::new(PolicyEngine::default()); + install_host_defaults(&engine, false); + // Both are set-once, so a second set reports the existing one. + assert!(!engine.set_http_transport(Arc::new(HyperTransport::new()))); + assert!(!engine.set_secret_providers(registry_with_vault(Arc::new(HyperTransport::new())))); + // The builtin kind resolves: a load fails on the plugin's own + // settings, not on an unknown kind. + let err = engine + .load_config_yaml( + r#" +plugins: + - name: jwt + kind: identity/jwt + hooks: [identity.resolve] + config: + not_a_setting: true +"#, + ) + .expect_err("the plugin settings are invalid") + .to_string(); + assert!(err.contains("config parse failed"), "{err}"); + } + + #[test] + fn secret_error_kinds_are_stable() { + let cases = [ + ( + SecretError::Config { + message: "m".into(), + }, + "config", + ), + ( + SecretError::NotFound { + reference: "r".into(), + }, + "not_found", + ), + ]; + for (err, kind) in cases { + assert_eq!(secret_error_kind(&err), kind); + } + } + + #[test] + fn resolved_identity_fields_lists_only_output_slots() { + let input: IdentityPayload = serde_json::from_value(serde_json::json!({ + "source": "bearer", + "source_header": "authorization", + "headers": {"authorization": "Bearer x"}, + "client_host": "10.0.0.1", + "client_port": 443, + })) + .expect("input payload"); + assert!(resolved_identity_fields(&input).is_empty()); + + let forged: IdentityPayload = serde_json::from_value(serde_json::json!({ + "source": "bearer", + "subject": {"id": "alice"}, + "client": {"client_id": "app"}, + "caller_workload": {"spiffe_id": "spiffe://example.org/app"}, + "delegation": {"depth": 0}, + "raw_credentials": {"inbound_tokens": {}}, + "resolved_at": "2026-01-01T00:00:00Z", + "raw_claims": {"sub": "alice"}, + })) + .expect("forged payload"); + assert_eq!( + resolved_identity_fields(&forged), + vec![ + "subject", + "client", + "caller_workload", + "delegation", + "raw_credentials", + "resolved_at", + "raw_claims" + ] + ); + } +} diff --git a/crates/cpex/src/lib.rs b/crates/cpex/src/lib.rs index abc88f42..a5c67ee2 100644 --- a/crates/cpex/src/lib.rs +++ b/crates/cpex/src/lib.rs @@ -9,153 +9,154 @@ //! capability it invokes: tools, prompts, resources, inference providers, and //! A2A methods. Each operation runs through a policy-defined pipeline that can //! resolve identity, make an authorization decision (delegated to an engine -//! like Cedar or CEL), exchange and reduce credentials before a downstream -//! call, redact inputs and outputs, track information flow across calls, and -//! audit. You write that policy declaratively in APL, the configuration that -//! defines each operation's pipeline; CPEX evaluates and enforces it at the -//! boundary, against state the model cannot observe or forge. +//! like Cedar, CEL or OPA), exchange and reduce credentials before a downstream +//! call, track information flow across calls, and audit. You write that policy +//! declaratively in APL, the configuration that defines each operation's +//! pipeline; CPEX evaluates and enforces it at the boundary, against state the +//! model cannot observe or forge. //! //! - Guide and concepts: //! - Source and issues: //! //! # This crate //! -//! `cpex` is the **host facade**: one dependency that re-exports the CPEX -//! runtime (`cpex-core`, `apl-core`, `apl-cmf`, `apl-cpex`), so a host depends -//! on this crate instead of pinning each of them separately. +//! `cpex` is the **host facade** over the praxis-policy engine (PPE). It +//! re-exports the whole public surface of [`praxis_policy`], built with every +//! stable builtin and the bundled hyper HTTP transport, so a host depends on +//! this one crate. Every item documented on `praxis_policy` is reachable here +//! under the same name: [`PolicyEngine`], [`install_builtins`], [`prelude`], +//! [`AplOptions`], the builtin factory types, [`HyperTransport`] and +//! [`install_default_http_transport`]. //! -//! By default it is the **engine only**: no builtin plugins are compiled in. -//! The bundled extension set lives in [`cpex-builtins`](cpex_builtins) and is -//! pulled in only when a builtins feature is enabled. The out-of-process -//! Python plugin host is separate again, behind `python-host`. +//! Two CPEX-only additions sit on top: the [`host`] module, which holds the +//! wiring the C FFI and the Python wheel share ([`host::install_host_defaults`], +//! [`host::secret_error_kind`], the [`host::REMOVED_HOOKS`] table and the +//! inbound identity check), and the out-of-process Python plugin host, behind +//! the `python-host` feature. //! //! # Usage //! -//! Engine only (register your own factories): -//! //! ```no_run //! use std::sync::Arc; -//! use cpex::PluginManager; -//! -//! let mgr = Arc::new(PluginManager::default()); -//! // ... register host factories, then `apl_cpex::register_apl(&mgr, opts)`. -//! ``` -//! -//! With the bundled builtins (enable the `builtins` or `full` feature): -//! -//! ```ignore -//! use std::sync::Arc; -//! use cpex::PluginManager; -//! -//! let mgr = Arc::new(PluginManager::default()); -//! // Register every enabled builtin factory and install the APL config -//! // visitor (in-process defaults) in one call: -//! cpex::install_builtins(&mgr); -//! // ... then load a config that references the enabled `kind`s. +//! use cpex::PolicyEngine; +//! +//! let engine = Arc::new(PolicyEngine::default()); +//! // Register every builtin factory and install the APL config visitor +//! // (in-process defaults) in one call: +//! cpex::install_builtins(&engine); +//! // Builtins that call out over HTTP (JWKS, token exchange, CIBA, Vault) +//! // need a transport. The bundled one refuses private destinations. +//! cpex::install_default_http_transport(&engine); +//! // ... then load a config that references the builtin `kind`s. //! ``` //! //! # Features //! -//! No plugins are on by default (`cpex = "0.2"` is the engine alone). -//! `builtins` enables the common in-process set; `full` adds the Valkey -//! session store; or pick a granular subset (`jwt`, `oauth`, `pii`, -//! `audit`, `cedar`, `cel`, `valkey`). When any builtins feature is on, the -//! registration helpers and the concrete factory types are re-exported here -//! from [`cpex-builtins`](cpex_builtins). +//! Every stable PPE builtin is always compiled in. The PII scanner and audit +//! logger are not builtins: they are reference plugins a host registers +//! itself. //! -//! `python-host` is orthogonal to all of those, and is in neither `builtins` -//! nor `full`. It pulls -//! [`cpex-hosts-python`](cpex_hosts_python), which runs existing Python CPEX -//! plugins out-of-process — one cached virtualenv and one `worker.py` -//! subprocess per plugin — and re-exports [`IsolatedVenvFactory`] and -//! [`ISOLATED_VENV_KIND`] here. Unlike the builtins there is no -//! `install_*` helper: register the factory yourself, because the `kind` is -//! one host serving arbitrarily many Python plugins. +//! `python-host` pulls `cpex-hosts-python`, which runs existing Python CPEX +//! plugins out-of-process (one cached virtualenv and one `worker.py` +//! subprocess per plugin), and re-exports `IsolatedVenvFactory` and +//! `ISOLATED_VENV_KIND` here. There is no `install_*` helper for it: +//! register the factory yourself, because the `kind` is one host serving +//! arbitrarily many Python plugins. //! //! ```ignore -//! use cpex::{IsolatedVenvFactory, PluginManager, ISOLATED_VENV_KIND}; -//! use cpex::cpex_core::factory::PluginFactoryRegistry; +//! use std::sync::Arc; +//! use cpex::{IsolatedVenvFactory, PolicyEngine, ISOLATED_VENV_KIND}; //! -//! let mut factories = PluginFactoryRegistry::new(); -//! factories.register(ISOLATED_VENV_KIND, Box::new(IsolatedVenvFactory)); -//! let mgr = PluginManager::from_config(config, &factories)?; -//! // `initialize()` builds each venv and launches its worker — a cold pip +//! let engine = Arc::new(PolicyEngine::default()); +//! engine.register_factory(ISOLATED_VENV_KIND, Box::new(IsolatedVenvFactory)); +//! engine.load_config_yaml(yaml)?; +//! // `initialize()` builds each venv and launches its worker. A cold pip //! // install is measured in minutes, so do it at startup, not on demand. -//! mgr.initialize().await?; +//! engine.initialize().await?; //! ``` -// Whole-crate re-exports for advanced use (types not surfaced below). -pub use {apl_cmf, apl_core, apl_cpex, cpex_core}; +pub mod host; -pub use apl_core::step::PdpFactory; -pub use apl_cpex::{ - register_apl, AplOptions, DispatchCache, MemorySessionStore, SessionStore, SessionStoreFactory, -}; -pub use cpex_core::manager::PluginManager; +// The engine's facade, whole and by name, so `cpex::X` resolves for every +// public `praxis_policy::X`. +pub use praxis_policy; +pub use praxis_policy::*; -// The whole aggregator, for advanced use. -#[cfg(feature = "cpex-builtins")] -pub use cpex_builtins; // The whole Python host crate, for advanced use (venv and worker internals). #[cfg(feature = "python-host")] pub use cpex_hosts_python; - -// Registration helpers — delegated to cpex-builtins, keeping the facade's -// historical names (`register_builtin_plugins`, `builtin_pdp_factories`). -#[cfg(feature = "cpex-builtins")] -pub use cpex_builtins::{ - builtin_pdps as builtin_pdp_factories, builtin_session_store_factories, install_builtins, - register_builtins as register_builtin_plugins, -}; - -// Concrete factory types + KIND consts, each behind its facade feature -// (which forwards to the matching cpex-builtins feature). -#[cfg(feature = "cedar")] -pub use cpex_builtins::CedarDirectPdpFactory; -#[cfg(feature = "cel")] -pub use cpex_builtins::CelPdpFactory; -#[cfg(feature = "audit")] -pub use cpex_builtins::{AuditLoggerFactory, AUDIT_KIND}; -#[cfg(feature = "jwt")] -pub use cpex_builtins::{JwtIdentityFactory, JWT_KIND}; -#[cfg(feature = "oauth")] -pub use cpex_builtins::{OAuthDelegatorFactory, OAUTH_KIND}; -#[cfg(feature = "pii")] -pub use cpex_builtins::{PiiScannerFactory, PII_KIND}; -#[cfg(feature = "valkey")] -pub use cpex_builtins::{ValkeyConfig, ValkeySessionStoreFactory, VALKEY_KIND}; // The Python host's `KIND` is renamed on re-export: bare `KIND` at the facade -// root says nothing about which plugin kind it is, and the builtins above all -// use a prefixed const. +// root says nothing about which plugin kind it is, and the builtins all use a +// prefixed const. #[cfg(feature = "python-host")] pub use cpex_hosts_python::{IsolatedVenvFactory, KIND as ISOLATED_VENV_KIND}; -#[cfg(all(test, feature = "cpex-builtins"))] +#[cfg(test)] mod tests { use super::*; + use praxis_policy_core::config::parse_config; use std::sync::Arc; #[test] fn install_builtins_runs_without_panic() { - let mgr = Arc::new(PluginManager::default()); - install_builtins(&mgr); + let engine = Arc::new(PolicyEngine::default()); + install_builtins(&engine); + } + + /// A config written against the engine's own schema parses into a + /// `PolicyConfig` through the facade's re-exported core crate. + #[test] + fn policy_config_parses_through_the_facade() { + let config = parse_config( + r#" +plugins: + - name: jwt + kind: identity/jwt + hooks: [identity.resolve] +"#, + ) + .expect("valid config"); + assert_eq!(config.plugins.len(), 1); + assert_eq!(config.plugins[0].hooks, vec!["identity.resolve".to_owned()]); + } + + /// The engine rejects the unprefixed hook names older CPEX configs used. + #[test] + fn legacy_hook_name_is_rejected() { + let engine = Arc::new(PolicyEngine::default()); + install_builtins(&engine); + let err = engine + .load_config_yaml( + r#" +plugins: + - name: jwt + kind: identity/jwt + hooks: [tool_pre_invoke] +"#, + ) + .expect_err("legacy hook names are removed"); + let err = err.to_string(); + assert!(err.contains("'cmf.tool_pre_invoke'"), "{err}"); } } + #[cfg(all(test, feature = "python-host"))] mod python_host_tests { use super::*; - use cpex_core::config::parse_config; - use cpex_core::factory::PluginFactoryRegistry; + use praxis_policy_core::config::parse_config; + use praxis_policy_core::factory::PluginFactoryRegistry; /// A config with one `isolated_venv` plugin, mirroring the YAML shape an /// operator writes. No `plugin_dirs`: the host always resolves - /// `/plugins` — see `plugin::DEFAULT_PLUGIN_DIR`. + /// `/plugins`. fn minimal_config_yaml() -> &'static str { r#" +engine_settings: + dispatch: hooks plugins: - name: pii-filter kind: isolated_venv - hooks: [tool_pre_invoke] + hooks: [cmf.tool_pre_invoke] config: class_name: my_pkg.filters.PiiFilter "# @@ -163,7 +164,7 @@ plugins: /// The facade's re-exported `IsolatedVenvFactory` and kind const are /// wired up well enough to instantiate a plugin from config. This stops - /// at `from_config` deliberately — `initialize()` is what builds the venv + /// at `from_config` deliberately: `initialize()` is what builds the venv /// and spawns `worker.py`, which needs a real interpreter and a real /// package, so it belongs in cpex-hosts-python's integration tests. #[test] @@ -173,10 +174,37 @@ plugins: let mut factories = PluginFactoryRegistry::new(); factories.register(ISOLATED_VENV_KIND, Box::new(IsolatedVenvFactory)); - let mgr = PluginManager::from_config(config, &factories) + let engine = PolicyEngine::from_config(config, &factories) .expect("isolated_venv factory is registered, so instantiation succeeds"); - assert_eq!(mgr.plugin_count(), 1); - assert!(mgr.has_hooks_for("tool_pre_invoke")); + assert_eq!(engine.plugin_count(), 1); + assert!(engine.has_hooks_for("cmf.tool_pre_invoke")); + } + + /// The documented path: register the facade's factory on an engine, then + /// load YAML. Covers the identity and delegation hooks too, whose handlers + /// must pass the engine's hook-family check. + #[test] + fn register_factory_then_load_yaml_wires_the_python_host() { + let engine = std::sync::Arc::new(PolicyEngine::default()); + engine.register_factory(ISOLATED_VENV_KIND, Box::new(IsolatedVenvFactory)); + engine + .load_config_yaml( + r#" +engine_settings: + dispatch: hooks +plugins: + - name: py-identity + kind: isolated_venv + hooks: [identity.resolve, token.delegate, cmf.tool_pre_invoke] + config: + class_name: my_pkg.Resolver +"#, + ) + .expect("the isolated_venv kind resolves through the facade"); + + for hook in ["identity.resolve", "token.delegate", "cmf.tool_pre_invoke"] { + assert!(engine.has_hooks_for(hook), "no handler on {hook}"); + } } } diff --git a/crates/cpex/tests/facade.rs b/crates/cpex/tests/facade.rs new file mode 100644 index 00000000..cfcb3385 --- /dev/null +++ b/crates/cpex/tests/facade.rs @@ -0,0 +1,129 @@ +// Location: ./crates/cpex/tests/facade.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// The facade, exercised the way a host uses it: only `cpex::` paths. + +use std::sync::Arc; + +use cpex::PolicyEngine; +use cpex::prelude::*; + +/// Load a one-plugin config on an engine with the builtins installed and +/// return the error text (empty on success). +fn load_error_for_kind(kind: &str) -> String { + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + let yaml = + format!("plugins:\n - name: probe\n kind: {kind}\n hooks: [cmf.tool_pre_invoke]\n"); + match engine.load_config_yaml(&yaml) { + Ok(()) => String::new(), + Err(e) => e.to_string(), + } +} + +/// The PII scanner and audit logger are reference plugins now, not builtins, +/// so the facade must not register them. +#[test] +fn install_builtins_registers_no_pii_or_audit_kind() { + for kind in ["validator/pii-scan", "audit/logger"] { + let err = load_error_for_kind(kind); + assert!( + err.contains("no factory registered") && err.contains(kind), + "{kind} must not be registered by install_builtins; got: {err}" + ); + } +} + +/// Denies any tool call whose message text mentions a banned word. +struct WordBlocker { + cfg: PluginConfig, +} + +impl Plugin for WordBlocker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for WordBlocker { + async fn handle( + &self, + payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.message.get_text_content().contains("banned") { + return PluginResult::deny(PluginViolation::new( + "facade.banned_word", + "the payload mentions a banned word", + )); + } + PluginResult::allow() + } +} + +struct WordBlockerFactory; + +impl PluginFactory for WordBlockerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(WordBlocker { + cfg: config.clone(), + }); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + Ok(PluginInstance { + plugin, + handlers: vec![("cmf.tool_pre_invoke", adapter)], + }) + } +} + +const CONFIG: &str = r#" +engine_settings: + dispatch: hooks +plugins: + - name: word-blocker + kind: test/word-blocker + hooks: [cmf.tool_pre_invoke] + mode: sequential +"#; + +async fn invoke(engine: &PolicyEngine, text: &str) -> (bool, Option) { + let payload = MessagePayload { + message: Message::text(Role::User, text), + }; + let (result, _background) = engine + .invoke_by_name( + "cmf.tool_pre_invoke", + Box::new(payload), + Extensions::default(), + None, + ) + .await; + (result.continue_processing, result.violation.map(|v| v.code)) +} + +/// A config on `cmf.tool_pre_invoke` loads and dispatches through the facade, +/// with the builtins installed alongside a host-supplied factory. +#[tokio::test] +async fn cmf_tool_pre_invoke_config_loads_and_dispatches() { + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + engine.register_factory("test/word-blocker", Box::new(WordBlockerFactory)); + engine.load_config_yaml(CONFIG).expect("config loads"); + engine.initialize().await.expect("engine initializes"); + + assert!(engine.has_hooks_for("cmf.tool_pre_invoke")); + + let (allowed, violation) = invoke(&engine, "hello").await; + assert!(allowed, "clean payload is allowed"); + assert_eq!(violation, None); + + let (allowed, violation) = invoke(&engine, "a banned word").await; + assert!(!allowed, "banned payload is denied"); + assert_eq!(violation.as_deref(), Some("facade.banned_word")); + + engine.shutdown().await; +} diff --git a/deny.toml b/deny.toml index f0feb79f..1b3c4264 100644 --- a/deny.toml +++ b/deny.toml @@ -5,26 +5,11 @@ [advisories] unmaintained = "all" maximum-db-staleness = "P30D" -# Every entry below is in a DEV-only or BUILD-time path — none ship in a +# Every entry below is in a DEV-only or BUILD-time path; none ship in a # published library crate's runtime graph (verified via `cargo deny check # advisories` dependency trees). Revisit when the upstream test/build deps # release fixes. ignore = [ - # --- Dev-only: testcontainers test harness for cpex-session-valkey --- - # tar/PAX extraction flaws in astral-tokio-tar pulled by testcontainers; - # only run by the #[ignore]d Valkey integration tests. - "RUSTSEC-2026-0066", - "RUSTSEC-2026-0112", - "RUSTSEC-2026-0113", - "RUSTSEC-2026-0145", - # rustls-pemfile unmaintained, via bollard -> testcontainers (dev-only). - "RUSTSEC-2025-0134", - # --- Dev-only: RSA keypair generation in cpex-plugin-identity-jwt tests --- - # Marvin timing sidechannel in `rsa`; no fixed release exists. `rsa` is a - # dev-dependency only (fixture keygen for the JWT tests): the runtime uses - # jsonwebtoken's `aws_lc_rs` backend, not the `rust_crypto` backend that - # would pull `rsa` into the shipped tree. So it never reaches consumers. - "RUSTSEC-2023-0071", # --- Build-time proc-macro: biscuit-quote -> biscuit-auth --- # proc-macro-error2 unmaintained; runs only at compile time, never shipped. "RUSTSEC-2026-0173", diff --git a/deploy/valkey-compose.yml b/deploy/valkey-compose.yml index b25fe54c..01199937 100644 --- a/deploy/valkey-compose.yml +++ b/deploy/valkey-compose.yml @@ -2,17 +2,17 @@ # Copyright 2026 # SPDX-License-Identifier: Apache-2.0 # -# Local development / integration Valkey for the cpex-session-valkey -# backend. Brings up a single Valkey primary configured the way the -# security model requires (see docs/operations/valkey-session-store.md): +# Local development / integration Valkey for PPE's Valkey session store +# (`kind: valkey`, registered by `cpex::install_builtins`). Brings up a +# single Valkey primary configured the way the security model requires +# (see docs/operations/valkey-session-store.md): # # - maxmemory-policy noeviction: a full instance fails writes closed -# rather than silently evicting accumulated taint labels (R9). +# rather than silently evicting accumulated taint labels. # # Usage: # docker compose -f deploy/valkey-compose.yml up -d -# VALKEY_TEST_URL=redis://127.0.0.1:6379 \ -# cargo test -p cpex-session-valkey --test valkey_store_integration -- --ignored +# make parity PARITY_VALKEY_URL=redis://127.0.0.1:6379/0 # # This is a DEV/TEST topology only — no TLS, no ACL. Production deployments # must add TLS (mTLS recommended), a least-privilege ACL, and HA via a diff --git a/docs/brainstorms/ppe-core-adoption-requirements.md b/docs/brainstorms/ppe-core-adoption-requirements.md new file mode 100644 index 00000000..11bd406a --- /dev/null +++ b/docs/brainstorms/ppe-core-adoption-requirements.md @@ -0,0 +1,140 @@ +--- +date: 2026-10-07 +topic: ppe-core-adoption +--- + +# Adopt PPE as the CPEX Core (CPEX 0.3.0) + +## Summary + +CPEX 0.3.0 replaces its own engine core and builtins with the published Praxis Policy Engine (PPE) crates. It keeps the surfaces PPE does not have (Python host, C FFI, Python and Go bindings, delegator-biscuit, tutorial, standalone docs) and moves all of them to PPE's API and policy format. A CPEX-side acceptance suite and a parity check against 0.2.3 confirm that operations still work. + +--- + +## Problem Frame + +The CPEX Rust core was donated to the Praxis org as PPE. The port was extracted at CPEX commit `aed0f15`, with OPA added from `fa222c4`; both anchors are recorded in PPE's changelog (0.1.0 entry) and `docs/dev/port-provenance.md` in the PPE repo. Since then PPE has moved to 0.4.1. Along the way it renamed the public API (`PluginManager` became `PolicyEngine`, `cpex-*` and `apl-*` crates became `praxis-policy-*`), folded the SDK into a prelude module, required hosts to supply an HTTP transport, changed the policy document (dispatch defaults to `policy`, about ten keys removed, unreached plugins fail the load), merged its extensions into one builtins crate, and turned `pii-scanner` and `audit-logger` into unpublished reference plugins. + +CPEX still carries its own copy of everything PPE now owns: core, orchestration, three APL crates, the SDK, the builtins crate, and nine builtin plugins, PDPs and session stores. The two copies drift. Every fix lands twice or not at all, and CPEX users run an engine that is falling behind the one the same maintainers ship upstream. Meanwhile the CPEX-only surfaces (Python host, FFI, Python and Go bindings, tutorial) are what CPEX users actually integrate with, and they are all wired to the old core and the old `PluginManager` name. + +--- + +## Actors + +- A1. Rust embedder: depends on the `cpex` crate directly and registers its own plugin factories. +- A2. Python integrator: uses the `cpex` wheel, often through legacy `PluginManager` code. +- A3. Go integrator: uses the `go/cpex` package over the C FFI. +- A4. Operator: writes the policy YAML that CPEX loads. +- A5. Maintainer: maintains both PPE and CPEX and cuts the 0.3.0 release. + +--- + +## Requirements + +**Dependency and crate layout** +- R1. CPEX depends on PPE through released crates.io versions only, pinned to an exact release in the 0.4.x line, and this includes PPE's builtins. No path or git dependency on PPE remains in released manifests. +- R2. CPEX removes every crate that PPE replaces: core, orchestration, the three APL crates, the SDK, the builtins crate, and the builtin plugins, PDPs and session store that PPE already has (identity-jwt, delegator-oauth, elicitation-ciba, cedar-direct, cel, opa, valkey). +- R3. CPEX removes its `pii-scanner` and `audit-logger` crates. It does not re-host PPE's reference versions. +- R4. CPEX keeps `delegator-biscuit` as a CPEX-owned plugin, ported to PPE's plugin API, built and tested in the CPEX workspace. +- R5. CPEX keeps the out-of-process Python plugin host and the C FFI, both ported to PPE's API. + +**Public API** +- R6. Every CPEX surface uses PPE's names: Rust, Python, Go, FFI-facing docs, examples and the tutorial. `PluginManager` becomes `PolicyEngine`, and the other renames PPE made since the port follow the same way. The Go constructors are renamed to match (for example, `NewPolicyEngine`). +- R7. The Python package keeps `PluginManager` as an alias of `PolicyEngine`. It is the only compatibility alias on any surface. +- R8. No Rust or Go compatibility aliases or shims for the old names. + +**Builtins shipped in bindings** +- R9. The Python wheel and the Go FFI library ship every non-experimental builtin PPE publishes. That adds OPA, CIBA, api-key identity and the Vault secret provider to the current set. PPE's experimental quota is not shipped. +- R10. The Python wheel and Go FFI library do not ship `pii-scanner` or `audit-logger`. Using them requires a Rust host. +- R11. Builtins that make outbound HTTP calls (JWT/JWKS, OAuth token exchange, CIBA, Vault) keep working from Python and Go without integrators writing transport code. + +**Policy configuration** +- R12. CPEX accepts exactly the policy document PPE accepts. It does no pre-processing, key rewriting or legacy-key acceptance. +- R13. Every config shipped in CPEX (examples, tutorial modules, test fixtures, docs snippets) is ported to the PPE format and loads cleanly, including PPE's load-time checks such as unreached plugins. +- R14. A migration guide maps each removed or changed 0.2.x config key, hook name and behavior to its 0.3.0 form. It also covers the dispatch-mode default, newly required plugin capabilities, and the new load-time failures. + +**Documentation** +- R15. CPEX keeps its own complete docs site. Docs are revised for every API and config change, not dropped in favor of PPE's. +- R16. CPEX docs gain the current PPE material that applies to CPEX users (for example HTTP routing, assertions, auditing, identity claim mapping, CMF extensions, and the crate map), adapted to CPEX's audience and surfaces. +- R17. Docs show how a Rust host registers PPE's reference `pii-scanner` and `audit-logger` plugins through the host plugin registration path. They also state that Python and Go builds do not include them. +- R18. The 0.3.0 changelog lists every breaking change across Rust, Python, Go and config, and links the migration guide. + +**Verification** +- R19. Existing CPEX suites pass after the port: Rust workspace tests, Python host e2e, FFI tests, Go tests (including race), Python binding tests, and tutorial checks. +- R20. A new CPEX-side acceptance suite drives PPE through CPEX's own surfaces (Python bindings, Go/FFI, Python host) for the key flows: config load, identity, delegation, PDP decisions, session labels, and elicitation where practical. It covers what PPE's own e2e suites cannot reach. +- R25. Python support keeps working end to end after the refactor: the PyO3 wheel builds, imports and runs policies, and the out-of-process Python plugin host (`isolated_venv` plugins) loads and runs plugins on the new engine, with its e2e suites green in CI. +- R21. A parity check runs representative configs against CPEX 0.2.3 and 0.3.0 (after config migration) and confirms the same allow/deny decisions, violation codes and plugin effects. Any difference is documented as intended in the migration guide or fixed. + +**Build, CI and release** +- R22. Makefile targets, CI workflows and release workflows are updated for the new crate layout, the PPE dependency, and the new test suites. Targets for removed crates are dropped, the new acceptance and parity checks are wired in, and the Python wheel and Go release builds keep working. +- R23. The release ships as CPEX 0.3.0. + +**Contribution hygiene** +- R26. CPEX moves to the same Rust edition as PPE (2024, resolver 3), as its own change separate from the port. +- R24. Commits are signed off (`-s`), use short conventional commit messages, and carry no AI attribution trailers. + +--- + +## Acceptance Examples + +- AE1. **Covers R7, R6.** Given Python code that does `from cpex import PluginManager` and builds an engine from a migrated config, when it runs on 0.3.0, it works unchanged. Given Go code calling `NewPluginManager`, it fails to compile and the migration guide names `NewPolicyEngine`. +- AE2. **Covers R12, R14.** Given a 0.2.x config that uses a removed key, when 0.3.0 loads it, the load fails with PPE's unknown-key error. The migration guide has an entry for that key. +- AE3. **Covers R10, R17.** Given a Python config declaring a `pii-scanner` plugin, when it loads, the kind is reported as unknown. The docs explain that pii-scanner needs a Rust host and show how to register it there. +- AE4. **Covers R11.** Given a Go integrator using an identity-jwt plugin with a JWKS URL and no transport setup, when a request arrives, the JWT is validated through PPE's transport, the same as on 0.2.3. +- AE5. **Covers R21.** Given a representative 0.2.3 config and its migrated 0.3.0 form, when both evaluate the same request set, the decisions and violation codes match, or each difference is listed as intended. + +--- + +## Success Criteria + +- Python integrators and Python plugin authors keep working on 0.3.0: the wheel and the `isolated_venv` host both pass their suites. +- A CPEX user can upgrade to 0.3.0 by following the migration guide alone, and their policy decisions do not change silently. +- CPEX no longer contains a second copy of engine code. Engine fixes land once, in PPE, and reach CPEX by a version bump. +- A planner can sequence the work without inventing which crates survive, what the bindings ship, how configs are treated, or what "not broken" means. + +--- + +## Scope Boundaries + +- No config migration tool, and no acceptance of 0.2.x keys at load time. +- No compatibility aliases on Rust or Go. The Python `PluginManager` alias is the only one. +- No `pii-scanner` or `audit-logger` in Python or Go builds. +- No PPE experimental quota in bindings. +- No new engine features or behavior changes inside PPE, apart from upstreaming fixes CPEX needs (see Key Decisions). +- No moving CPEX docs into PPE, and no retiring CPEX docs in favor of PPE's. +- No Praxis-side consumer changes. + +--- + +## Key Decisions + +- Clean break on names everywhere except the Python alias: legacy Python integrations are the most fragile and the brief asked for it. Elsewhere, aliases would keep both names alive indefinitely. +- Config follows PPE exactly, with a guide rather than a tool or shim: a shim would recreate the drift this work removes, and PPE's stricter load checks are a safety gain worth keeping. +- crates.io only, exact 0.4.x pin: CPEX releases depend on what PPE has actually released, so 0.3.0 is reproducible. PPE has shipped breaking changes inside 0.4.x, so the pin is exact. If CPEX needs a PPE change, PPE ships a 0.4.x patch first. +- `cpex` stays as the host-facing facade crate and re-exports PPE's facade, so Rust embedders keep one entry point and CPEX-only features hang off it. +- Bindings install PPE's default HTTP transport on the integrator's behalf, because Python and Go users cannot plug in a Rust transport and identity, delegation and CIBA must keep working. +- Bindings mirror PPE's non-experimental builtin set, which brings OPA and CIBA into Python and Go for the first time, alongside api-key and Vault. +- Post-port CPEX changes are reconciled upstream: CPEX has 13 commits after `aed0f15`. The few that touch core or builtins (for example a CEL resolver change and identity-jwt test changes) are checked against PPE, and anything missing is upstreamed to PPE rather than kept as a CPEX patch. +- `pii-scanner` and `audit-logger` stay out of bindings: they are unpublished in PPE, and shipping them from CPEX would make CPEX their de facto publisher. + +--- + +## Dependencies / Assumptions + +- PPE 0.4.x on crates.io exposes everything CPEX's Python host, FFI and biscuit plugin need through its public API. Gaps become PPE changes and releases, which gate CPEX 0.3.0. +- PPE's toolchain floor (edition 2024, its MSRV) becomes CPEX's floor, and CPEX adopts the same edition so both repos share idioms and lint behavior. +- The PPE local clone at `../praxis-policy` is a reference for porting and for upstream fixes, not a build input. +- An older plan, `docs/plans/2026-08-08-001-feat-praxis-policy-engine-port-plan.md`, covered creating PPE. This work is its CPEX-side follow-up. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R6][Technical] Whether C ABI symbol names in the FFI are renamed along with the Go names, or kept stable underneath a renamed Go surface. +- [Affects R5][Technical] Which PPE public APIs the Python host and FFI need that may not be public today, and whether any require a PPE release first. +- [Affects R21][Technical] Which configs make up the representative parity set, and how the parity check runs against 0.2.3 (pinned release artifact vs. tagged build). +- [Affects R20][Needs research] Which acceptance flows are already covered by PPE's own e2e suites, so the CPEX suite only adds coverage for CPEX surfaces. +- [Affects R16][Technical] Which PPE doc pages are adopted as-is, adapted, or summarized with a link. +- [Affects R22][Technical] How removed crates' published crates.io names are handled (final deprecation release or just left at 0.2.3). diff --git a/docs/content/_index.md b/docs/content/_index.md index 4ef0cdb6..2bb9bdf3 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -38,3 +38,7 @@ Existing authorization systems (RBAC, ABAC, Cedar, OPA, AuthZEN) answer whether [Vision →]({{< relref "/docs/vision" >}}) {{% /columns %}} + +--- + +**Upgrading from 0.2?** CPEX 0.3 runs on the praxis-policy engine. API names, hook names and several config keys changed; [Migrating to 0.3]({{< relref "/docs/migration-0.3" >}}) maps each one. diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index 9f584362..b766593b 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -10,4 +10,6 @@ CPEX is a policy enforcement runtime for AI agents: a deterministic reference mo Start with the [Vision]({{< relref "/docs/vision" >}}) for the reference-monitor model, the [Threat Model]({{< relref "/docs/threat-model" >}}) for what CPEX defends against at each placement, or the [Use Cases]({{< relref "/docs/use-cases" >}}) for the controls running end-to-end behind a real gateway. Then the [Quick Start]({{< relref "/docs/quickstart" >}}) stands up an enforcement point, and [APL]({{< relref "/docs/apl" >}}) is where you write policy. Prefer to learn by doing? The [Tutorial]({{< relref "/docs/tutorial" >}}) builds a working enforcement point one capability at a time, with runnable code you can edit and re-run. +Configuring a deployment? [Configuration]({{< relref "/docs/configuration" >}}) covers the document, [Builtins]({{< relref "/docs/builtins" >}}) the bundled plugins and the HTTP transport, [HTTP Routing]({{< relref "/docs/http-routing" >}}) and [Header Assertions]({{< relref "/docs/assertions" >}}) the generic HTTP path, [Auditing]({{< relref "/docs/auditing" >}}) the decision and effect records, and the [Crate Map]({{< relref "/docs/crates" >}}) what each crate holds. Upgrading from 0.2.x? Start with [Migrating to 0.3]({{< relref "/docs/migration-0.3" >}}). + Using the Python 0.1.x line? Its docs are preserved under [0.1.x (Legacy)]({{< relref "/docs/0.1.x" >}}). diff --git a/docs/content/docs/apl/_index.md b/docs/content/docs/apl/_index.md index 183e1530..4325b86a 100644 --- a/docs/content/docs/apl/_index.md +++ b/docs/content/docs/apl/_index.md @@ -5,7 +5,7 @@ weight: 30 # APL: configuring enforcement pipelines -APL is the declarative configuration that defines a CPEX enforcement pipeline. Each capability an agent can invoke (a tool, resource, prompt, or A2A method) defines its own pipeline through a **route** that sequences the controls protecting it, evaluated at the boundary. You describe the conditions and the effects; you do not write enforcement logic in application code. +APL (Authorization Policy Layer) is the declarative configuration that defines a CPEX enforcement pipeline. Each capability an agent can invoke (a tool, resource, prompt, or A2A method) defines its own pipeline through a **route** that sequences the controls protecting it, evaluated at the boundary. You describe the conditions and the effects; you do not write enforcement logic in application code. ![An APL config: plugins and global settings, then per-entity routes with a pre-invocation flow (require, PDP, delegate, run) and post-invocation result handling (taint, redact), plus session tainting across entities](images/apl_overview.png) @@ -33,7 +33,7 @@ Policy is organized by **route**: an operation CPEX mediates, identified by the The first `deny` in any phase halts that phase and every later phase. Nothing reaches the backend after a deny in `args` or `authorization.pre_invocation`. -`authorization` names *when* the phase runs, not a pure allow/deny gate: alongside the decision, `pre_invocation` (and `post_invocation`) can carry obligations and effects — `taint(...)`, `delegate(...)`, and `plugin(...)` (which may transform the payload) — that run as part of the phase. +`authorization` names *when* the phase runs, not a pure allow/deny gate: alongside the decision, `pre_invocation` (and `post_invocation`) can carry obligations and effects that run as part of the phase: `taint(...)`, `delegate(...)`, and `run(...)` (which may transform the payload). ```yaml routes: @@ -50,7 +50,7 @@ routes: employee_id: "str | mask(4)" ``` -The `pre_invocation:` / `post_invocation:` lists may also be written flat on the route (without the `authorization:` wrapper); both forms are equivalent. +`pre_invocation:` and `post_invocation:` nest under `authorization:`. Each phase is an ordered list of rules and effects, and an `authorization:` block must contribute at least one step: an open route writes `pre_invocation: ["allow"]`. Writing a phase list flat on the route, or wrapping terms in `apl:`, is a load error in 0.3 (see [Migrating to 0.3]({{< relref "/docs/migration-0.3#config-keys" >}})). ## Predicates @@ -96,12 +96,12 @@ For richer conditionals, use the `when` / `do` form, where `do` is a single effe - when: "role.hr & !perm.view_ssn" do: - "taint(restricted, session)" - - "plugin(audit-log)" + - "run(audit-log)" ``` ## Custom denial response -By default a deny surfaces a reason and code, and the host renders its own denial. A route can instead attach a custom HTTP response — status, body, headers — through a `response:` block, a sibling of the route's `authorization:` block: +By default a deny surfaces a reason and code, and the host renders its own denial. A route can instead attach a custom HTTP response (status, body, headers) through a `response:` block, a sibling of the route's `authorization:` block: ```yaml routes: @@ -116,11 +116,11 @@ routes: WWW-Authenticate: "Bearer" ``` -All three fields are optional; an absent block leaves the host's default denial unchanged. When the route denies, the status/body/headers are carried on the violation for the host to render on the wire. `response:` is honored at route scope and at `global` scope (below); it is inert — and warns at load time — under `defaults` or a policy bundle. It is scope-local: a `global` `response:` is not inherited by entity routes. +All three fields are optional; an absent block leaves the host's default denial unchanged. When the route denies, the status/body/headers are carried on the violation for the host to render on the wire. `response:` is honored at route scope and at `global` scope (below); it is inert, and warns at load time, under `defaults` or a policy bundle. It is scope-local: a `global` `response:` is not inherited by entity routes. ## Authorizing HTTP requests without an entity -Routes key on an MCP / A2A entity — a tool, prompt, resource, or LLM. A generic HTTP request that carries no such entity is authorized by the `global` policy instead: when `global` declares an `authorization:` (or `args:`) block, CPEX evaluates it for these requests, reading the request line (`http.method`, `http.path`, `http.host`, `http.scheme`) and headers. Pair it with a `global` `response:` to return a custom denial. +Routes key on an MCP / A2A entity: a tool, prompt, resource, or LLM. A generic HTTP request that carries no such entity is authorized by the `global` policy instead, or by an `http:` route that selects on the request line (see [HTTP Routing]({{< relref "/docs/http-routing" >}})). When `global` declares an `authorization:` (or `args:`) block, CPEX evaluates it for these requests, reading the request line (`http.method`, `http.path`, `http.host`, `http.scheme`) and headers. Pair it with a `global` `response:` to return a custom denial. ```yaml global: @@ -154,12 +154,23 @@ The accepted stages: | Constraint validators | `enum(a, b, c)`, `regex("...")`, `len(1..100)`, range like `0..100` | | Transforms | `mask(N)` (keep last N), `redact`, `redact(!predicate)` (redact unless), `omit`, `hash` | | Scans | `pii.redact`, `pii.detect`, `injection.scan` | -| Dispatch | `plugin(name)` (alias `run(name)`), `taint(label[, scope])` | +| Dispatch | `run(name)`, `taint(label[, scope])` | -Named-validator dispatch (`validate(name)`) is not implemented in the current build. Use `regex("...")` for pattern checks or `plugin(name)` to hand a field to a plugin. +The scan stages do no detection. `pii.detect` and `injection.scan` label the session (`PII`, `injection`) whenever the stage runs, and `pii.redact` also replaces the value with `[REDACTED]`. For real detection, hand the field to a plugin with `run(name)`. + +`plugin(name)` is not a spelling here or in step position: `run(name)` is the one form that invokes a plugin, and `plugin(name)` fails the load naming the replacement. + +Named-validator dispatch (`validate(name)`) is refused at load. Use `regex("...")` for pattern checks, or `run(name)` to hand a field to a plugin. Inside a quoted literal, only `\\`, `\'` and `\"` are escapes, so a regex class needs a doubled backslash: `regex("\\d+")`. ## Effects beyond predicates A `pre_invocation:` rule can also call a PDP, mint a delegated token, or invoke a plugin. Those effects and how they sequence are covered in [Effects]({{< relref "/docs/apl/effects" >}}). -Every fragment on this page is drawn from the `apl-core` parser tests and the reference deployments, so the forms shown here parse as written. +## Beside the policy + +Two blocks sit alongside `authorization:` on the same sections and are not policy terms themselves: + +- `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. diff --git a/docs/content/docs/apl/attributes.md b/docs/content/docs/apl/attributes.md index 43a7c341..d5b8e242 100644 --- a/docs/content/docs/apl/attributes.md +++ b/docs/content/docs/apl/attributes.md @@ -5,17 +5,17 @@ weight: 35 # Static Attributes -Policy reads attributes. Most come from the request: the verified subject and its roles ([Identity]({{< relref "/docs/apl/identity" >}})), request headers, session labels. But some attributes are carried by nothing and fetched from nowhere — they are **operator-maintained facts** known at configuration time. Which region a tenant's data is resident in. Which models an agent is allowed to use. The org's default region. These are the *static attributes*, and they live in a plain data tree under the `data.*` namespace. +Policy reads attributes. Most come from the request: the verified subject and its roles ([Identity]({{< relref "/docs/apl/identity" >}})), request headers, session labels. But some attributes are carried by nothing and fetched from nowhere: they are **operator-maintained facts** known at configuration time. Which region a tenant's data is resident in. Which models an agent is allowed to use. The org's default region. These are the *static attributes*, and they live in a plain data tree under the `data.*` namespace. This is the counterpart to identity resolution. Identity turns a token into `subject.*` and `role.*`; static provisioning turns a config file into `data.*`. Both feed the same attribute bag predicates read. ## The requirement -An EU tenant's data must stay in-region (see [Backend Restriction]({{< relref "/docs/apl/restrict" >}})). To enforce that, policy has to know *which* region a given tenant is resident in — a fact that is not in the caller's token and does not belong in application code. It is an operator's decision, maintained alongside the deployment. Policy needs to read it per request, keyed by the caller's tenant. +An EU tenant's data must stay in-region (see [Backend Restriction]({{< relref "/docs/apl/restrict" >}})). To enforce that, policy has to know *which* region a given tenant is resident in, a fact that is not in the caller's token and does not belong in application code. It is an operator's decision, maintained alongside the deployment. Policy needs to read it per request, keyed by the caller's tenant. ## The data tree -Static attributes are a plain nested document. The operator organizes it however they like — by tenant, team, environment — and everything lives under a top-level `data:` mapping: +Static attributes are a plain nested document. The operator organizes it however they like (by tenant, team, environment), and everything lives under a top-level `data:` mapping: ```yaml # attributes/tenants.yaml @@ -31,52 +31,52 @@ The whole tree flattens into the bag under `data.*`: `data.org.default_region`, ## Loading it -List the attribute files in APL's config namespace. They deep-merge, in order, into one `data.*` tree: +List the attribute files under `global.attribute_files`. They deep-merge, in order, into one `data.*` tree: ```yaml global: - apl: - attribute_files: - - attributes/org.yaml - - attributes/tenants.yaml - - attributes/agents.yaml + attribute_files: + - attributes/org.yaml + - attributes/tenants.yaml + - attributes/agents.yaml ``` -Different subtrees combine freely — `org.yaml` sets `data.org.*`, `tenants.yaml` sets `data.tenants.*`. The merge is **fail-fast**: two files setting the *same* leaf to different values is a load-time error, not a silent last-wins, and a file that forgets the `data:` wrapper is rejected. A configuration mistake stops the gateway from starting rather than producing quietly-wrong routing. +Different subtrees combine freely: `org.yaml` sets `data.org.*`, `tenants.yaml` sets `data.tenants.*`. The merge is **fail-fast**: two files setting the *same* leaf to different values is a load-time error, not a silent last-wins, and a file that forgets the `data:` wrapper is rejected. A configuration mistake stops the gateway from starting rather than producing quietly-wrong routing. -The built-in loader reads files. A host whose attributes live in etcd, a database, or a k8s ConfigMap implements the `AttributeSource` trait, loads the tree at startup, and injects it in code — an injected tree takes precedence over the declarative file list. +The built-in loader reads files. A host whose attributes live in etcd, a database, or a k8s ConfigMap implements the `AttributeSource` trait, loads the tree at startup, and injects it in code. An injected tree takes precedence over the declarative file list. ## Reading it in policy Two ways, depending on whether the path is fixed or keyed by the request. -**Dot-path** — a fixed lookup: +**Dot-path**, a fixed lookup: ```yaml - "data.org.default_region == 'eu': deny('org is EU-only')" ``` -**Interpolation** — index the tree by a *request* value, using `[...]`: +**Interpolation**: index the tree by a *request* value, using `[...]`: ```yaml routes: - llm: "*" - pre_invocation: - - when: "data.tenants[subject.tenant].data_region == 'eu'" - do: - - restrict: { allow_regions: [eu], on_empty: deny } + authorization: + pre_invocation: + - when: "data.tenants[subject.tenant].data_region == 'eu'" + do: + - restrict: { allow_regions: [eu], on_empty: deny } ``` -`[subject.tenant]` is resolved at evaluation time: the caller's `subject.tenant` (say `acme-eu`) is substituted into the path, so the predicate reads `data.tenants.acme-eu.data_region`. This is what makes the tree useful — "look up *this caller's* tenant" rather than a single hard-coded path. +`[subject.tenant]` is resolved at evaluation time: the caller's `subject.tenant` (say `acme-eu`) is substituted into the path, so the predicate reads `data.tenants.acme-eu.data_region`. This is what makes the tree useful: "look up *this caller's* tenant" rather than a single hard-coded path. -If the indexed value is missing — no `subject.tenant` on this request — the whole path resolves to *absent*, and the predicate is simply false (and a `require(...)` on it fails closed). A lookup keyed on an unknown value never matches a half-built key. +If the indexed value is missing (no `subject.tenant` on this request), the whole path resolves to *absent*, and the predicate is simply false (and a `require(...)` on it fails closed). A lookup keyed on an unknown value never matches a half-built key. -Beyond predicates, a `data.*` reference can be the **value of a `restrict` field** — `allow_models: "data.agents[subject.id].allowed_models"` — so a single routing rule reads each caller's own allow-list from the tree. See [Backend Restriction]({{< relref "/docs/apl/restrict" >}}). +Beyond predicates, a `data.*` reference can be the **value of a `restrict` field**, `allow_models: "data.agents[subject.id].allowed_models"`, so a single routing rule reads each caller's own allow-list from the tree. See [Backend Restriction]({{< relref "/docs/apl/restrict" >}}). ## Data, not a rules engine -The tree holds **literal values only**: no conditionals, no computed fields, no references to other entries. Any "if X then Y" is policy's job — put it in a route. This guardrail is structural rather than enforced: a plain data document has no syntax to express logic, so the static layer cannot quietly grow into a second, shadow policy engine. It provisions the facts; APL decides with them. +The tree holds **literal values only**: no conditionals, no computed fields, no references to other entries. Any "if X then Y" is policy's job: put it in a route. This guardrail is structural rather than enforced: a plain data document has no syntax to express logic, so the static layer cannot quietly grow into a second, shadow policy engine. It provisions the facts; APL decides with them. ## How it connects to the pipeline -`data.*` is an ordinary bag namespace (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})): predicates read it exactly like `subject.*` or `session.labels`, and it composes with them freely — `data.tenants[subject.tenant].data_region` ties a static fact to a per-request identity in one predicate. The tree is loaded once at startup and shared across requests, so reading it costs nothing on the hot path. Where [Identity]({{< relref "/docs/apl/identity" >}}) supplies the *dynamic* attributes a request carries, static provisioning supplies the *stable* ones a deployment maintains — together they are the full picture a predicate reasons about. +`data.*` is an ordinary bag namespace (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})): predicates read it exactly like `subject.*` or `session.labels`, and it composes with them freely: `data.tenants[subject.tenant].data_region` ties a static fact to a per-request identity in one predicate. The tree is loaded once at startup and shared across requests, so reading it costs nothing on the hot path. Where [Identity]({{< relref "/docs/apl/identity" >}}) supplies the *dynamic* attributes a request carries, static provisioning supplies the *stable* ones a deployment maintains. Together they are the full picture a predicate reasons about. diff --git a/docs/content/docs/apl/delegation.md b/docs/content/docs/apl/delegation.md index 539f0b80..ac478569 100644 --- a/docs/content/docs/apl/delegation.md +++ b/docs/content/docs/apl/delegation.md @@ -36,6 +36,8 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] + # read the caller's inbound token, write the minted one, call the IdP + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: "https://idp.example.com/realms/agents/protocol/openid-connect/token" client_id: "cpex-gateway" @@ -47,6 +49,10 @@ plugins: It exchanges the caller's inbound token for one scoped to `audience` with the requested `permissions`, and attaches it to the outbound request. The result populates delegation attributes that later rules read. +The route must also resolve identity (an `authentication:` list at some scope), or there is no inbound token to exchange; the load reports such a route under `delegation_without_identity_resolution`. A token endpoint the host transport refuses (for example a private address without the host's opt-in) denies with `delegation.egress_denied`. A denial from the IdP carries the OAuth `error` code, not the IdP's `error_description`. + +Delegation runs from a Rust host, or from Go with `InvokeResolved`. The Python bindings do not carry inbound tokens into a delegation step. + ## Choosing who the exchange is for By default a `delegate` step exchanges the **user's** token: the minted credential speaks for the user, on-behalf-of style. Two step keys change that: @@ -56,13 +62,13 @@ By default a `delegate` step exchanges the **user's** token: the minted credenti | `subject` | Whose identity the minted token speaks for. | `user`, `client`, `caller_workload`, `this_workload` | `user` | | `actor` | An additional credential recorded as the RFC 8693 `actor_token`, naming who is *acting*. | `user`, `client`, `caller_workload` | none | -`actor` accepts only inbound credentials, because an actor is by definition a party that presented itself to us. `subject` additionally accepts `this_workload` — us — which is the one principal with no inbound credential at all. (`gateway` is accepted as a deprecated alias of `this_workload`, from before CPEX was decoupled from the gateway shape.) +`actor` accepts only inbound credentials, because an actor is by definition a party that presented itself to us. `subject` additionally accepts `this_workload` (us), which is the one principal with no inbound credential at all. (`gateway` is accepted as a deprecated alias of `this_workload`, from before CPEX was decoupled from the gateway shape.) ### On-behalf-of a user, with the agent named The common agentic shape: a user asked for something, and an agent is carrying it out. The user is the subject; the calling agent's SVID rides along as the actor, so CPEX asks the token service to record `act` alongside `sub`, letting the backend see both parties. -Whether `act` lands in the minted token is the token service's call: RFC 8693 distinguishes *impersonation* (subject only, no `act`) from *delegation* (`act` records the actor), and only a service implementing the delegation path emits it. CPEX always sends the delegation request. Notably, Keycloak's Standard Token Exchange (v2) implements impersonation only and silently ignores `actor_token`, so no `act` appears there — capture the actor at the CPEX boundary (audit / downstream header) if your token service doesn't support delegation. +Whether `act` lands in the minted token is the token service's call: RFC 8693 distinguishes *impersonation* (subject only, no `act`) from *delegation* (`act` records the actor), and only a service implementing the delegation path emits it. CPEX always sends the delegation request. Notably, Keycloak's Standard Token Exchange (v2) implements impersonation only and silently ignores `actor_token`, so no `act` appears there. Capture the actor at the CPEX boundary (audit / downstream header) if your token service doesn't support delegation. ```yaml pre_invocation: @@ -93,12 +99,12 @@ pre_invocation: audience: workday-api, permissions: [read_compensation])" ``` -`subject: this_workload` has no inbound credential to exchange, so the delegator switches from RFC 8693 token exchange to an RFC 6749 §4.4 **`client_credentials`** grant: no `subject_token` is sent, and this instance's identity is the OAuth client identity it already authenticates with. Nothing extra to configure — the delegator's existing `client_id` / `client_secret` *is* this instance's identity. +`subject: this_workload` has no inbound credential to exchange, so the delegator switches from RFC 8693 token exchange to an RFC 6749 §4.4 **`client_credentials`** grant: no `subject_token` is sent, and this instance's identity is the OAuth client identity it already authenticates with. Nothing extra to configure: the delegator's existing `client_id` / `client_secret` *is* this instance's identity. Two consequences worth understanding before choosing this shape: - **This instance is the only enforcement point.** The backend sees a token that says "this instance" and has no idea which agent triggered the call. Whatever the `require` gates allow is what happens; there is no second opinion downstream. Gate accordingly. -- **The minted token cannot name the calling agent.** `actor_token` is a token-exchange parameter with no meaning under `client_credentials`, so it is not sent even if the step asks for one. Attribution to the calling agent lives in your audit log, not in the credential. Carrying the agent inside the token requires this instance to have a real subject credential of its own to exchange — its own SVID — which is not yet wired up. +- **The minted token cannot name the calling agent.** `actor_token` is a token-exchange parameter with no meaning under `client_credentials`, so it is not sent even if the step asks for one. Attribution to the calling agent lives in your audit log, not in the credential. Carrying the agent inside the token requires this instance to have a real subject credential of its own to exchange (its own SVID), which is not yet wired up. ### Who the minted token speaks for @@ -111,7 +117,7 @@ Each exchange is attributed to exactly one principal, and the attribution is **d | `caller_workload` | `as_caller_workload` | The calling agent | | `this_workload` | `as_this_workload` | This instance itself | -There is deliberately **no `mode` key**. If routes could declare the attribution independently of the credential they hand over, a route could claim to act on behalf of a user while exchanging a workload SVID. Deriving it means the operator states one thing — which credential — and the consequence follows. +There is deliberately **no `mode` key**. If routes could declare the attribution independently of the credential they hand over, a route could claim to act on behalf of a user while exchanging a workload SVID. Deriving it means the operator states one thing (which credential) and the consequence follows. The attribution is load-bearing beyond the audit trail: minted credentials are cached per `(subject, workload, audience, scopes, attribution)`, so tokens minted for one calling agent are never served to another. diff --git a/docs/content/docs/apl/effects.md b/docs/content/docs/apl/effects.md index a54665da..4bc104c3 100644 --- a/docs/content/docs/apl/effects.md +++ b/docs/content/docs/apl/effects.md @@ -13,13 +13,13 @@ An APL rule does something. That something is an **effect**. Effects are the bui |--------|--------------| | `allow` | No-op. Continue to the next effect. | | `deny` / `deny('reason')` / `deny('reason', 'code')` | Halt the phase and all later phases with a violation. | -| `plugin(name)` (alias `run(name)`) | Invoke a registered plugin (PII scan, audit log, custom check). | +| `run(name)` | Invoke a registered plugin (audit log, custom check, content scan). `plugin(name)` from 0.2.x is rejected. | | `delegate(name, ...)` | Mint a downstream credential via a delegator plugin. See [Delegation]({{< relref "/docs/apl/delegation" >}}). | | `require_approval(name, ...)` / `confirm(...)` / `require_step_up(...)` / `require_attestation(...)` / `request_info(...)` / `require_review(...)` | Ask a human and suspend the operation until they respond. See [Elicitation]({{< relref "/docs/apl/elicitation" >}}). | | `taint(label[, scope])` | Attach a label to the session or message. See [Session Tainting]({{< relref "/docs/apl/tainting" >}}). | | `restrict: { ... }` | Narrow the set of backends the router may select from. See [Backend Restriction]({{< relref "/docs/apl/restrict" >}}). | | field pipelines | Validate or transform `args`/`result` fields. See [APL]({{< relref "/docs/apl" >}}). | -| PDP call (`cedar:`, `cel:`, `opa(...)`) | Delegate the decision to a policy engine. See [PDP Integration]({{< relref "/docs/apl/pdp" >}}). | +| PDP call (`cedar:`, `cel:`, `opa:`) | Delegate the decision to a policy engine. See [PDP Integration]({{< relref "/docs/apl/pdp" >}}). | ## Sequencing and halt-on-deny @@ -75,4 +75,4 @@ authorization: ## Phases recap -Effects run within the four route phases: `args`, `authorization.pre_invocation`, `result`, `authorization.post_invocation` (see [APL]({{< relref "/docs/apl" >}})). `delegate`, elicitation verbs, and PDP calls belong in `pre_invocation` or `post_invocation`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. An elicitation verb can also *suspend* a phase — the operation neither allows nor denies, but pauses for a human and resumes on retry (see [Elicitation]({{< relref "/docs/apl/elicitation" >}})). +Effects run within the four route phases: `args`, `authorization.pre_invocation`, `result`, `authorization.post_invocation` (see [APL]({{< relref "/docs/apl" >}})). `delegate`, elicitation verbs, and PDP calls belong in `pre_invocation` or `post_invocation`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. An elicitation verb can also *suspend* a phase: the operation neither allows nor denies, but pauses for a human and resumes on retry (see [Elicitation]({{< relref "/docs/apl/elicitation" >}})). diff --git a/docs/content/docs/apl/elicitation.md b/docs/content/docs/apl/elicitation.md index fc917ff2..1c765809 100644 --- a/docs/content/docs/apl/elicitation.md +++ b/docs/content/docs/apl/elicitation.md @@ -5,11 +5,11 @@ weight: 45 # Human-in-the-Loop Elicitation -Some operations should not proceed on the caller's say-so alone. A large payroll change wants a manager's approval; a destructive action wants the user to confirm; a sensitive read wants a fresh second factor. Elicitation lets a policy **pause an operation to ask a human** and resume once they respond — without blocking the request path, and with the decision bound to the actual request rather than to an LLM's paraphrase of it. +Some operations should not proceed on the caller's say-so alone. A large payroll change wants a manager's approval; a destructive action wants the user to confirm; a sensitive read wants a fresh second factor. Elicitation lets a policy **pause an operation to ask a human** and resume once they respond, without blocking the request path, and with the decision bound to the actual request rather than to an LLM's paraphrase of it. ## The requirement -The scenario's `approve_raise` may run only after the requester's manager approves, and the approval must cover the specific amount being requested. The manager is not the caller, may take hours to respond, and their answer must be genuine — a signed decision from the right person, not a value the agent asserts. CPEX must dispatch the ask, hold the operation open across the agent's retries, and verify the response before letting the tool run. +The scenario's `approve_raise` may run only after the requester's manager approves, and the approval must cover the specific amount being requested. The manager is not the caller, may take hours to respond, and their answer must be genuine: a signed decision from the right person, not a value the agent asserts. CPEX must dispatch the ask, hold the operation open across the agent's retries, and verify the response before letting the tool run. ## Elicitation as an effect @@ -31,8 +31,8 @@ routes: - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" ``` -- `from` is **who to ask** — an attribute reference resolved against the request bag (here `claim.manager`, the requester's manager, who differs from the subject). An attribute `from` that doesn't resolve fails closed rather than dispatching to a bogus identity. -- `scope` is the **args binding** — an APL boolean expression the runtime checks against the live request when the response comes back. +- `from` is **who to ask**: an attribute reference resolved against the request bag (here `claim.manager`, the requester's manager, who differs from the subject). An attribute `from` that doesn't resolve fails closed rather than dispatching to a bogus identity. +- `scope` is the **args binding**: an APL boolean expression the runtime checks against the live request when the response comes back. - `purpose` is the **audited, human-readable** description of what is being asked. The other verbs (`confirm`, `require_step_up`, `require_attestation`, `request_info`, `require_review`) parse to the same effect with a different kind; each selects the validation contract the runtime applies to the response. @@ -41,11 +41,11 @@ The other verbs (`confirm`, `require_step_up`, `require_attestation`, `request_i An elicitation has three short, synchronous touch-points. The hours-long human gap lives in the **channel** (e.g. Keycloak CIBA), never in a blocking call: -1. **Dispatch** — first arrival: register the intent, open the channel backchannel, return a correlation id. -2. **Check** — on each agent retry: read status (`pending` / `resolved` / `expired`) without blocking. -3. **Validate** — once resolved: verify the response is *genuine*, then the runtime layers the `scope`-over-args *sufficiency* check before honoring the approval. +1. **Dispatch**, on first arrival: register the intent, open the channel backchannel, return a correlation id. +2. **Check**, on each agent retry: read status (`pending` / `resolved` / `expired`) without blocking. +3. **Validate**, once resolved: verify the response is *genuine*, then the runtime layers the `scope`-over-args *sufficiency* check before honoring the approval. -While pending, the phase **suspends** rather than denies. The decision stays `Allow`, but a pending marker rides alongside it, and the host maps that to JSON-RPC **`-32120`** ("not complete — retry echoing this id"). The forward rule is one clause: *forward only when the decision is `Allow` and nothing is pending.* Expiry, channel error, a genuine denial, or a failed validation all fail closed (default `on_error: deny`). +While pending, the phase **suspends** rather than denies. The decision stays `Allow`, but a pending marker rides alongside it, and the host maps that to JSON-RPC **`-32120`** ("not complete, retry echoing this id"). The forward rule is one clause: *forward only when the decision is `Allow` and nothing is pending.* Expiry, channel error, a genuine denial, or a failed validation all fail closed (default `on_error: deny`). ![The elicitation suspend-and-resume flow: an agent request hits require_approval, which opens a Keycloak CIBA backchannel and returns -32120 with an elicitation id; agent retries hit a non-blocking status check that keeps returning pending until the channel resolves; validate then verifies genuineness and scope over the live args, forwarding to the tool when approved and sufficient, and failing closed on denial, expiry, or invalid responses](images/apl_elicitation_flow.png) @@ -58,6 +58,7 @@ plugins: - name: manager-approver kind: elicitation/ciba hooks: [elicit] + capabilities: [perform_http] # calls the OP's CIBA and token endpoints config: backchannel_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/ext/ciba/auth" token_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/token" @@ -72,7 +73,7 @@ plugins: ## Elicitation attributes -Dispatch and resolution write `elicitation.*` attributes that later rules in the same phase — and the audit log — can read: +Dispatch and resolution write `elicitation.*` attributes that later rules in the same phase, and the audit log, can read: | Attribute | Meaning | |-----------|---------| @@ -86,11 +87,13 @@ Dispatch and resolution write `elicitation.*` attributes that later rules in the Two independent checks stand between an approval and the tool call, and it is worth understanding where each is enforced: -- **Genuineness** is the channel plugin's job. For CIBA, the approver identity is extracted from the token the OP returns and cross-checked against the `login_hint`. The plugin trusts the token because it comes straight from the OP over a client-authenticated TLS poll — it does **not** independently verify the JWT signature. That trust therefore rests on the token endpoint being reached over correctly configured TLS with client authentication; deploy accordingly (always `https://`, real client credentials) and do not point a CIBA handler at a plaintext or unauthenticated endpoint outside local development. +- **Genuineness** is the channel plugin's job. For CIBA, the approver identity is extracted from the token the OP returns and cross-checked against the `login_hint`. The plugin trusts the token because it comes straight from the OP over a client-authenticated TLS poll; it does **not** independently verify the JWT signature. That trust therefore rests on the token endpoint being reached over correctly configured TLS with client authentication; deploy accordingly (always `https://`, real client credentials) and do not point a CIBA handler at a plaintext or unauthenticated endpoint outside local development. - **Sufficiency** is the runtime's job. Keycloak has no RFC 9396 rich authorization request, so the binding between "what was approved" and "what is being executed" lives in APL: the `scope:` expression is evaluated against the live request args at validation. A human can approve, but if the args drift outside `scope` (e.g. the amount was raised after approval), the operation fails closed regardless. -The `purpose` is recorded verbatim as the source of truth for what was approved — it is never derived from model output. +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. ## How it connects to the pipeline -`require_approval(...)` and its sibling verbs dispatch to a plugin implementing the `elicit` hook, resolved by name off the route's dispatch plan exactly like `delegate(...)`. Because elicitation is an explicit, sequenced effect — gated behind authentication, checked on every retry, and validated before the forward — a pending or unapproved operation never reaches the tool. +`require_approval(...)` and its sibling verbs dispatch to a plugin implementing the `elicit` hook, resolved by name off the route's dispatch plan exactly like `delegate(...)`. Because elicitation is an explicit, sequenced effect (gated behind authentication, checked on every retry, and validated before the forward), a pending or unapproved operation never reaches the tool. diff --git a/docs/content/docs/apl/identity.md b/docs/content/docs/apl/identity.md index 9e9c5bec..9b2ab852 100644 --- a/docs/content/docs/apl/identity.md +++ b/docs/content/docs/apl/identity.md @@ -20,6 +20,7 @@ plugins: - name: jwt-user kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] # fetches the issuer's JWKS config: role: user header: X-User-Token @@ -33,6 +34,25 @@ plugins: The token is verified against the issuer's JWKS. Only after verification do its claims become attributes. An unverified or expired token resolves to no subject, and `require(authenticated)` denies. +A `jwks_url` is an outbound call, so the plugin declares `perform_http` and the host installs an HTTP transport (see [Builtins]({{< relref "/docs/builtins#the-http-transport" >}})). The bundled transport refuses a JWKS URL on `localhost` or a private address unless the host opts in; a refused fetch denies with `auth.jwks_unavailable`. A token naming an unknown `kid` triggers one JWKS refetch, rate limited by `min_refresh_interval_secs`. + +## Which routes resolve identity + +Under the default `dispatch: policy`, identity plugins run only where a policy names them: an `authentication:` list on the route, a group it joins, its entity default, or `global:`. A route with none runs no identity resolution, so `authenticated` is false and no subject reaches it, even when the request carries a valid token. + +```yaml +global: + authentication: [jwt-user] # every route resolves the user + +routes: + - tool: get_compensation + authorization: + pre_invocation: + - "require(role.hr)" +``` + +Hosts that invoke `identity.resolve` themselves (Python and Go callers) pass the route's `meta.entity_type` and `meta.entity_name` with it, like any other hook, so the engine knows which route's `authentication:` to run. + ## What lands in the bag A resolved identity populates a flat attribute namespace that predicates read directly: @@ -46,7 +66,7 @@ A resolved identity populates a flat attribute namespace that predicates read di | OAuth client | `client.client_id`, `client.authorized_scopes`, `client.role.` | | Workload (SPIFFE / mTLS) | `caller_workload.spiffe_id`, `caller_workload.trust_domain` | -So `require(role.hr)` is true when the verified token carried the `hr` role, and `redact(!perm.view_ssn)` redacts unless it carried the `view_ssn` permission. +So `require(role.hr)` is true when the verified token carried the `hr` role, and `redact(!perm.view_ssn)` redacts unless it carried the `view_ssn` permission. Which claims become roles, permissions and teams is configurable per IdP; see [Identity Claim Mapping]({{< relref "/docs/identity-claim-mapping" >}}). ## Multiple sources @@ -69,6 +89,7 @@ plugins: - name: jwt-workload kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: caller_workload header: X-Workload-Token @@ -84,13 +105,13 @@ The SPIFFE ID is read from the SVID's `sub` claim, and the trust domain is deriv Note the distinction the bag makes between two different machine identities: -- **`caller_workload`** — the attested workload on the inbound network peer. The agent calling *us*. Many different agents call through one gateway. -- **`this_workload`** — this CPEX instance's *own* attested identity, used for outbound calls. A single principal. +- **`caller_workload`**: the attested workload on the inbound network peer. The agent calling *us*. Many different agents call through one gateway. +- **`this_workload`**: this CPEX instance's *own* attested identity, used for outbound calls. A single principal. -They are not interchangeable, and confusing them is how a token minted for one agent ends up presented by another. Delegation depends on the distinction — see [Delegation]({{< relref "/docs/apl/delegation" >}}). +They are not interchangeable, and confusing them is how a token minted for one agent ends up presented by another. Delegation depends on the distinction; see [Delegation]({{< relref "/docs/apl/delegation" >}}). ## How it connects to the pipeline -Identity resolution is a hook (`identity.resolve`) that runs ahead of the route's policy phase. The resolved subject is filtered by each downstream plugin's declared capabilities (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})): a plugin only sees the identity fields it is entitled to. APL predicates read the same bag, gated the same way. +Identity resolution is a hook (`identity.resolve`) that runs ahead of the route's policy phase, for the plugins the route's `authentication:` names. The resolved subject is filtered by each downstream plugin's declared capabilities (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})): a plugin only sees the identity fields it is entitled to. APL predicates read the same bag, gated the same way. Once identity is resolved, policy can authorize ([APL]({{< relref "/docs/apl" >}})), delegate downstream ([Delegation]({{< relref "/docs/apl/delegation" >}})), or hand a relationship decision to a PDP ([PDP Integration]({{< relref "/docs/apl/pdp" >}})). diff --git a/docs/content/docs/apl/pdp.md b/docs/content/docs/apl/pdp.md index 206118ad..c517d8a1 100644 --- a/docs/content/docs/apl/pdp.md +++ b/docs/content/docs/apl/pdp.md @@ -5,15 +5,24 @@ weight: 20 # PDP Integration -APL predicates handle attribute checks well: roles, permissions, scopes, comparisons. They are a poor fit for relationship questions ("is this user on the team that owns this repo?") and for policy you already maintain in a dedicated engine. For those, APL hands the decision to a **Policy Decision Point**. +APL predicates handle attribute checks well: roles, permissions, scopes, +comparisons. They are a poor fit for relationship questions ("is this user on +the team that owns this repo?") and for policy you already maintain in a +dedicated engine. For those, APL hands the decision to a Policy Decision +Point. -## The requirement +## Decision requirement -The scenario's repository search must allow a read when the caller is an engineer and the repo is internal, or when the caller is on the security team, regardless of repo. That is a relationship-and-attribute decision over entities, which is exactly what an engine like Cedar exists to express. APL should make the coarse gate and let the engine make the fine-grained call. +The scenario's repository search must allow a read when the caller is an +engineer and the repo is internal, or when the caller is on the security team, +regardless of repo. That is a relationship-and-attribute decision over entities, +which is exactly what an engine like Cedar exists to express. APL should make +the coarse gate and let the engine make the fine-grained call. ## Calling a PDP from policy -A PDP call is an effect in the `authorization.pre_invocation` phase. It names a dialect and passes the request; `on_allow` and `on_deny` react to the decision: +A PDP call is an effect in the `authorization.pre_invocation` phase. It names a +dialect and passes the request; `on_allow` and `on_deny` react to the decision: ```yaml authorization: @@ -26,11 +35,13 @@ authorization: id: ${args.repo_name} attributes: visibility: ${args.visibility} - on_deny: - - "deny('not permitted by repo policy', 'cedar_denied')" + on_deny: + - "deny('not permitted by repo policy', 'cedar_denied')" ``` -The cheap APL gate runs first. Only if it passes does CPEX evaluate the Cedar policy against the request entities. The Cedar policy itself lives in the config: +The cheap APL gate runs first. Only if it passes does CPEX evaluate the Cedar +policy against the request entities. The Cedar policy itself lives in the +config: ```yaml global: @@ -49,9 +60,17 @@ global: when { principal.roles.contains("security") }; ``` +A `pdp:` entry may carry an optional `cache:` block with positive +`ttl_seconds` and `max_entries`. Omission leaves evaluation uncached. +The cache stores Allow and Deny only; dispatch errors are never stored. +Keys are a digest of the dialect, the call arguments, the full attribute bag +and the structured input, so request data is not retained. External PDP policy +can still go stale until the TTL expires. + ## Supported dialects -APL recognizes a fixed set of PDP dialects. Three ship as builtin resolvers; the rest are recognized by APL and dispatched to a resolver you provide on the host. +APL recognizes a fixed set of PDP dialects. Three ship as builtin resolvers; the +rest are recognized by APL and dispatched to a resolver you provide on the host. | Dialect | Status | |---------|--------| @@ -61,7 +80,11 @@ APL recognizes a fixed set of PDP dialects. Three ship as builtin resolvers; the | `authzen` | Recognized dialect; wire a host resolver (AuthZEN protocol). | | `nemo` | Recognized dialect; wire a host resolver (NeMo Guardrails). | -This is a deliberate pluggable-resolver surface, not a maturity checklist. APL speaks the dialect; the resolver is an implementation. Cedar, CEL, and OPA are provided so you can start without writing one. For AuthZEN or NeMo, implement the resolver trait and register it; the APL `authzen:` / `nemo:` call forms then work unchanged. +This is a deliberate pluggable-resolver surface, not a maturity checklist. APL +speaks the dialect; the resolver is an implementation. Cedar, CEL, and OPA are +provided so you can start without writing one. For AuthZEN or NeMo, implement +the resolver trait and register it; the APL `authzen:` / `nemo:` call forms then +work unchanged. CEL is the lightest option for inline boolean policy: @@ -71,8 +94,347 @@ 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. +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. + +All three builtin resolvers ship in every CPEX surface: the Rust facade, the Python package, the Go module and the C ABI. + +## Structured request input + +The attribute bag holds scalars and string sets, so it cannot carry a list of +objects such as an LLM request's `tools`. OPA, CEL, and Cedar also receive two +JSON values beside the bag. APL predicates do not; they read the flat bag as +before. + +| Name | Where it is set | What it holds | +|---|---|---| +| `llm.request` | Any route whose host supplied it, including an `http:` route that proxies inference. | The request body as the host parsed it, from `Extensions.llm_request`. The slot is immutable, so plugins cannot replace it, and a PDP always sees the original body. | +| `args` | `tool:` routes | The first tool call's arguments, taken before any `args:` pipeline runs. Only an object counts. | + +Each engine names them as follows: + +| Engine | Request body | Tool arguments | +|---|---|---| +| OPA | `input.llm.request` | `input.args` | +| CEL | `llm.request` | `args` | +| Cedar | `context.llm.request` | `context.args` | + +The request document keeps the provider's wire shape. Prefer normalized bag +attributes for rules that must work across providers, and use `llm.request` +when the rule intentionally targets one provider format. + +Provider-side MCP declarations can be checked in `llm.request`, but their +later calls do not pass through a `tool:` route. Allowing a server here does +not add per-call policy enforcement for tools the provider invokes itself. + +In OPA and CEL, `llm.request` sits beside the existing `llm.model_id`, +`llm.provider`, and `llm.capabilities`. Structured `args` replaces the +flattened `args.*` keys, so a field has one type whatever the client sent. +See [Migrating `args` policies](#migrating-args-policies). Cedar never read +`args` from the bag, so `context.args` is new. APL predicates and `${args.X}` +substitution in step arguments still read the flattened bag. On `llm:` routes +there is no structured `args`, and `args` stays the prompt string. + +Structured input nested more than 128 levels deep, the limit serde_json +applies when parsing, is refused for any engine: every PDP step on that +request denies with the code `pdp.input_too_deep` and a reason that quotes no +value, and the engine never runs. A scalar is depth 1, and each enclosing +array or object adds one. APL-only steps are unaffected. + +### Types across engines + +| JSON | OPA | CEL | Cedar | +|---|---|---|---| +| object | object | map | record | +| array | array, order and duplicates kept | list, order and duplicates kept | set: unordered, duplicates collapsed | +| integer | number | `int` (`uint` above the `int` range) | `Long`; outside the `Long` range, a string | +| float such as `0.7` | number | `double` | string `"0.7"` | +| `null` | `null` | `null` | dropped, so the attribute is absent | +| object with an `__entity`, `__extn`, or `__expr` key | plain object | plain map | the step denies with `cedar.input_withheld` | +| document absent | undefined | missing key: evaluation error, so the step denies | missing attribute: evaluation error, so the step denies | + +A document the host did not supply is absent, never an empty object. + +Set `require_llm_request: true` on a PDP step when its decision depends on the +document. The evaluator then denies an absent document with +`pdp.llm_request_missing` before invoking the engine on any route. + +Structured `args` is the received value. An `args:` pipeline can change the +value forwarded to the tool after this snapshot is taken. + +### Absent and null values + +**OPA.** An absent document is undefined. An explicit `null` is a present +value: `input.args.note == null` holds, and `not input.args.note` does not. +To treat absent and `null` alike, write +`object.get(input.args, "note", null) == null`. What an undefined document +does depends on the decision shape the query returns: + +| Decision shape | Absent document | Safe idiom | +|---|---|---| +| Boolean `allow` | undefined, so the step denies | `default allow := false` | +| Decision object | undefined, so the step denies | build the object only when the rule holds | +| Deny set | every rule that reads it is undefined, the set is empty, and the step **allows** | add a rule that denies when the document is missing | + +Guard a deny set like this: + +```rego +package authz + +deny contains "request document missing" if not input.llm.request + +deny contains "model not allowed" if input.llm.request.model != "gpt-4o" +``` + +**CEL.** A reference to an absent document, or to a missing field, is an +evaluation error. The default `on_error: deny` turns it into a deny, so do not +set `on_error: allow` on a step that reads structured input. Guard an optional +field with `has()`: `!has(llm.request.tools) || size(llm.request.tools) < 8`. +`has()` is true for an explicit `null`, so test the value too when `null` must +count as missing: `has(args.note) && args.note != null`. + +**Cedar.** A `null` is dropped, so `has` is false for it. Reading an absent +document is a missing-attribute error, and any evaluation error denies the +step, in a `permit` or a `forbid`. Guard optional paths with `has`: +`context has llm && context.llm has request && context.llm.request has tools`. + +### Example: allowlist tool types + +This guard rejects an inference request when any entry in `tools[]` has a +`type` that matches none of the allowed patterns. A request with no `tools` +passes, a request with no document denies, and a tool with no string `type` +denies. Anchor each pattern: both engines search for a match anywhere in the +string. + +OPA, with `regex.match`: + +```yaml +global: + pdp: + - kind: opa + modules: + - | + package ssrf + + allowed_types := ["^function$", "^code_execution_[0-9]+$"] + + default allow := false + + allow if { + input.llm.request + not unlisted_tool + } + + unlisted_tool if { + some t in input.llm.request.tools + not type_allowed(t) + } + + type_allowed(t) if { + some pattern in allowed_types + regex.match(pattern, t.type) + } +routes: + - llm: "*" + authorization: + pre_invocation: + - opa: + query: data.ssrf.allow + require_llm_request: true +``` + +CEL, with `matches`. Extra keys on a `cel:` step become variables, so the +patterns live in the step as `allowed`: + +```yaml +global: + pdp: + - kind: cel +routes: + - llm: "*" + authorization: + pre_invocation: + - cel: + expr: >- + !has(llm.request.tools) || + llm.request.tools.all(t, allowed.exists(p, t.type.matches(p))) + allowed: ["^function$", "^code_execution_[0-9]+$"] +``` + +A request carrying `{"type": "web_search_20250305"}` denies under both. + +### Cedar limits + +Cedar's JSON form cannot hold every JSON value, so the structured input is +sanitized first: + +- `null` is dropped from objects and arrays. +- Floats, and integers outside the `Long` range, become strings. Compare them + as strings, or through `decimal()` when the value fits it. +- Arrays become sets: unordered, with duplicates collapsed. Cedar cannot count + or order array elements. +- An object with an `__entity`, `__extn`, or `__expr` key would be read as an + entity or extension value and silently change what a rule means. Such a key + anywhere in either value withholds the input, and the step denies with the + violation code `cedar.input_withheld` before Cedar runs, whether or not the + policy reads that value. + +A step's own `context:` may not define `llm` or `args`, since those keys carry +the structured input. Config load rejects such a step, including one nested in +a reaction. + +**With a schema.** A Cedar schema gives each action a closed context type, so +an undeclared `args` or `llm` key fails request validation and the step denies. +A `cedar-direct` resolver with `schema_text` or `schema_file` therefore +requires `structured_context: true` when a Cedar step reaches any route, +because the host can attach an LLM request document to each route. Config +load rejects a schema-backed Cedar step without the flag. Declare `llm` as +optional in the context type of every action such a step calls, and also +declare `args` for `tool:` routes: + +```yaml +global: + pdp: + - kind: cedar-direct + structured_context: true + policy_file: /etc/cpex/policy.cedar + schema_text: | + entity User = { ... }; + entity Tool; + action call appliesTo { + principal: User, + resource: Tool, + context: { + args?: { repo: String, limit?: Long }, + llm?: { request: { model: String } }, + }, + }; +``` + +Cedar record types are closed, so declare every field the input can carry, +after sanitizing: a float is a `String` and an array is a `Set`. A request +whose structured input does not match denies with `Cedar request validation +failed: context does not match the action's context type`. + +**Do not use Cedar for deny-lists over `llm.request.tools`.** Set `contains` +over records needs an exact match on the whole record. This rule misses a +forbidden tool that carries one extra field, such as a `name`: + +```text +forbid(principal, action, resource) +when { context.llm.request.tools.contains({"type": "web_search"}) }; +``` + +Cedar has no quantifier to test one field of each element. Write deny-lists +over `tools` in OPA or CEL. + +### Payload values in reasons and diagnostics + +Values under `args`, `result`, and `llm.request` come from the client, and a +deny reason can reach the client. Text a PDP generates therefore names types, +not values: + +- CEL diagnostics print `llm.request=map` or `args=list(2)`, and eval errors + name a fixed category. +- OPA eval errors give a category and the policy location, such as + `OPA eval error: policy runtime error at global-0.rego:2:33`. A result that + carries no decision is described by its type. +- Cedar errors name the policy and a category, such as + ``policy `limit-cap`: type error``. Entity, action, and request validation + errors name at most the entity type, never an id or value that `${args.X}` + filled in. + +Text the policy author wrote passes through unchanged: an OPA `reason` or +`message`, a string violation or its `msg`, and an APL `deny('...')` message. +Do not copy payload values into your own reasons, for example with +`sprintf("tool %v is not allowed", [t.type])` in Rego, because that text +reaches the client. + +### Host PDP resolvers + +The `read_llm_request` capability gates plugins, not PDP resolvers (see +[Extensions]({{< relref "/docs/extensions" >}})). A resolver is host code that already receives +the whole bag. The evaluator calls `PdpResolver::evaluate_structured(call, +bag, structured)`, whose default ignores `structured` and calls +`evaluate(call, bag)`. A host resolver opts in by overriding +`evaluate_structured` and reading `StructuredInput::llm_request` and +`StructuredInput::args`. It may override `validate_call` for call-local checks +and `validate_call_with_input` for checks that depend on the route's available +structured values. Input past the depth limit never reaches a resolver. + +### Migrating `args` policies + +This changed between CPEX 0.2.x and 0.3. On `tool:` routes, OPA and CEL used to read `args` from the flattened bag. A +scalar array arrived as a sorted, deduplicated set of strings, an explicit +`null` field was absent, and an array of objects was dropped. Now `args` is the +native JSON. Review every OPA and CEL rule that reads `args` on a `tool:` +route before upgrading. Rules that do not read `args`, APL predicates, and +Cedar `${args.X}` substitution are unaffected. + +Number and bool arrays hold numbers and bools, not strings: + +```rego +# before +deny contains "blocked id" if "13" in input.args.ids +# after +deny contains "blocked id" if 13 in input.args.ids +``` + +```text +before: !("13" in args.ids) +after: !(13 in args.ids) +``` + +Arrays keep the client's order, so a rule that relied on sorting must not +index or compare them as ordered: + +```rego +# before: relied on the sorted set +allow if input.args.regions == ["eu", "us"] +# after: compare as a set +allow if {r | some r in input.args.regions} == {"eu", "us"} +``` + +```text +before: args.regions == ["eu", "us"] +after: args.regions.all(r, r in ["eu", "us"]) && + ["eu", "us"].all(r, r in args.regions) +``` + +Arrays keep duplicates, so a count is no longer a count of distinct values: + +```rego +# before: counted distinct ids +allow if count(input.args.ids) <= 3 +# after +allow if count({i | some i in input.args.ids}) <= 3 +``` + +CEL has no set type. Write the rule with `all` or `exists`, whose answer does +not change with duplicates. + +An explicit `null` is present: + +```rego +# before: true for a missing or null note +allow if not input.args.note +# after +allow if object.get(input.args, "note", null) == null +``` + +```text +before: !has(args.note) +after: !has(args.note) || args.note == null +``` + +A rule that relied on an array of objects being dropped, such as +`not input.args.items`, now sees the array. ## 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. +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, the structured input, 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. + diff --git a/docs/content/docs/apl/restrict.md b/docs/content/docs/apl/restrict.md index 23c6c87c..177d33ee 100644 --- a/docs/content/docs/apl/restrict.md +++ b/docs/content/docs/apl/restrict.md @@ -5,9 +5,9 @@ weight: 60 # Backend Restriction -Some controls are not about *whether* an operation runs, but *where* it runs. "EU customer data must be inferred on EU backends." "This tenant may only use the cheap model tier." The agent asks for a **class** of backend — "inference" — and policy shapes which concrete backends are eligible. That shaping is the `restrict` effect. +Some controls are not about *whether* an operation runs, but *where* it runs. "EU customer data must be inferred on EU backends." "This tenant may only use the cheap model tier." The agent asks for a **class** of backend ("inference") and policy shapes which concrete backends are eligible. That shaping is the `restrict` effect. -`restrict` declares a predicate over backend attributes, narrowing the set of backends the host's router may select from. It never picks a backend and never allows or denies the request — the router still chooses the best survivor by health and load. Like `taint`, it is an **accumulating** effect: every `restrict` that fires only ever *narrows* the set, and multiple restrictions compose by conjunction. +`restrict` declares a predicate over backend attributes, narrowing the set of backends the host's router may select from. It never picks a backend and never allows or denies the request; the router still chooses the best survivor by health and load. Like `taint`, it is an **accumulating** effect: every `restrict` that fires only ever *narrows* the set, and multiple restrictions compose by conjunction. ## What `restrict` is not @@ -20,7 +20,7 @@ Some controls are not about *whether* an operation runs, but *where* it runs. "E ## The requirement -A tenant is EU-resident: its data must stay in-region. When the agent runs inference on that tenant's data, only EU inference backends are eligible — and if none are reachable, the request must fail rather than silently spill to a US backend. The agent never named a backend; it asked for "inference." The control has to live in routing, not in the prompt. +A tenant is EU-resident: its data must stay in-region. When the agent runs inference on that tenant's data, only EU inference backends are eligible, and if none are reachable, the request must fail rather than silently spill to a US backend. The agent never named a backend; it asked for "inference." The control has to live in routing, not in the prompt. ## Declaring a restriction @@ -30,26 +30,28 @@ The session is marked when EU-resident data is read (see [Session Tainting]({{< routes: # Reading data for an EU-resident tenant marks the session. - tool: fetch_customer - post_invocation: - - when: "data.tenants[subject.tenant].data_region == 'eu'" - do: - - "taint(eu_resident, session)" + authorization: + post_invocation: + - when: "data.tenants[subject.tenant].data_region == 'eu'" + do: + - "taint(eu_resident, session)" # Later inference is pinned to EU backends while that label is set. - llm: "*" - pre_invocation: - - when: "security.labels contains 'eu_resident'" - do: - - restrict: - allow_regions: [eu] - on_empty: deny # fail closed — never leave the region + authorization: + pre_invocation: + - when: "security.labels contains 'eu_resident'" + do: + - restrict: + allow_regions: [eu] + on_empty: deny # fail closed: never leave the region ``` `data.tenants[subject.tenant].data_region` indexes the tenant→region map by *this caller's* tenant; when it is `eu`, the session is tainted, and the inference route then narrows the candidate set to backends whose `region` label is `eu`. The router load-balances across the healthy EU backends. ## The constraint fields -A restriction is a small set of **typed fields** plus a `custom` label map. The shape is deliberately simple — it is a contract the host router evaluates against each backend's labels, not a predicate language. +A restriction is a small set of **typed fields** plus a `custom` label map. The shape is deliberately simple: it is a contract the host router evaluates against each backend's labels, not a predicate language. | Field | Backend attribute | Match | |-------|-------------------|-------| @@ -65,7 +67,7 @@ A restriction is a small set of **typed fields** plus a `custom` label map. The ## Per-caller values from the attribute tree -The set-valued fields (`allow_models`, `deny_models`, `allow_regions`, `allow_sites`) take either a literal list *or* a `data.*` reference into the [static attribute tree]({{< relref "/docs/apl/attributes" >}}) — resolved per request. This lets one rule serve every caller instead of hard-coding a block per agent or tenant: +The set-valued fields (`allow_models`, `deny_models`, `allow_regions`, `allow_sites`) take either a literal list *or* a `data.*` reference into the [static attribute tree]({{< relref "/docs/apl/attributes" >}}), resolved per request. This lets one rule serve every caller instead of hard-coding a block per agent or tenant: ```yaml # attributes/agents.yaml @@ -78,21 +80,22 @@ data: ```yaml routes: - llm: "*" - pre_invocation: - # allow_models is looked up from the tree by the caller's id. - - restrict: - allow_models: "data.agents[subject.id].allowed_models" + authorization: + pre_invocation: + # allow_models is looked up from the tree by the caller's id. + - restrict: + allow_models: "data.agents[subject.id].allowed_models" ``` -`support-bot` is restricted to `vllm/*`, `research-bot` to `anthropic/*` + `vllm/*` — one rule, values maintained in config. The distinction is by YAML shape: a **list** is a literal set; a bare **scalar** is a reference. Quote a reference that contains `[...]` (as above) so YAML doesn't read the brackets as an inline list. +`support-bot` is restricted to `vllm/*`, `research-bot` to `anthropic/*` + `vllm/*`: one rule, values maintained in config. The distinction is by YAML shape: a **list** is a literal set; a bare **scalar** is a reference. Quote a reference that contains `[...]` (as above) so YAML doesn't read the brackets as an inline list. -If the referenced path doesn't resolve — an agent absent from the tree, a missing `subject.id` — the field resolves to the **empty set**: nothing qualifies, so `on_empty` decides (deny by default). An unknown caller is never silently unconstrained. (`max_cost_tier` and `custom` are literal-only.) +If the referenced path doesn't resolve (an agent absent from the tree, a missing `subject.id`), the field resolves to the **empty set**: nothing qualifies, so `on_empty` decides (deny by default). An unknown caller is never silently unconstrained. (`max_cost_tier` and `custom` are literal-only.) ## Gating happens at the composition layer -`restrict` has no `when:` field of its own. Whether it fires is handled by APL's normal effect-gating — a `when:`/`do:` rule — exactly like every other effect. This keeps `restrict` orthogonal: it is *only* a set of backend constraints, and *whether* it applies is a normal predicate. So the two layers live apart: the `when:` gate is evaluated now, in CPEX, against the request; the constraint fields are evaluated later, by the host router, against its backends. +`restrict` has no `when:` field of its own. Whether it fires is handled by APL's normal effect-gating, a `when:`/`do:` rule, exactly like every other effect. This keeps `restrict` orthogonal: it is *only* a set of backend constraints, and *whether* it applies is a normal predicate. So the two layers live apart: the `when:` gate is evaluated now, in CPEX, against the request; the constraint fields are evaluated later, by the host router, against its backends. -Because it is accumulating, `restrict` composes anywhere an effect can appear — top-level, inside a `when` body, inside `sequential` / `parallel`, and inside a PDP's `on_allow` block. That last one is a first-class pattern: let Cedar make the fine-grained decision, then pin routing on allow. +Because it is accumulating, `restrict` composes anywhere an effect can appear: top-level, inside a `when` body, inside `sequential` / `parallel`, and inside a PDP's `on_allow` block. That last one is a first-class pattern: let Cedar make the fine-grained decision, then pin routing on allow. ```yaml pre_invocation: @@ -105,9 +108,9 @@ pre_invocation: ## How a restriction reaches the router -Multiple `restrict` effects in one request **fold** into a single constraint: allow-sets intersect, deny-sets and `custom` grow, tier ceilings combine, and `on_empty` takes the strictest. The result rides out on the typed `candidate_constraint` extension (see [Extensions]({{< relref "/docs/extensions" >}})) — the same in-process channel minted delegation tokens use, not a request header. +Multiple `restrict` effects in one request **fold** into a single constraint: allow-sets intersect, deny-sets and `custom` grow, tier ceilings combine, and `on_empty` takes the strictest. The result rides out on the typed `candidate_constraint` extension (see [Extensions]({{< relref "/docs/extensions" >}})), the same in-process channel minted delegation tokens use, not a request header. -The host router evaluates it. CPEX has no backend list and no live health, so it cannot run the match itself; it emits the constraint and the router — which owns the health-aware backend registry — checks each candidate's labels against it at selection time. +The host router evaluates it. CPEX has no backend list and no live health, so it cannot run the match itself; it emits the constraint and the router, which owns the health-aware backend registry, checks each candidate's labels against it at selection time. ```mermaid flowchart LR @@ -127,9 +130,9 @@ flowchart LR `on_empty` decides what happens when a restriction prunes every candidate. Only the router knows which backends are actually reachable, so the choice rides out with the constraint: -- **`deny`** (default) rejects the request. Correct for hard constraints like data sovereignty — never silently escape the region. +- **`deny`** (default) rejects the request. Correct for hard constraints like data sovereignty: never silently escape the region. - **`fallback`** reverts to the unconstrained set. An explicit opt-in for "prefer, but don't fail." ## How it connects to the pipeline -`restrict` is an effect like any other: it sequences with `require`, PDP calls, and `taint` (see [Effects]({{< relref "/docs/apl/effects" >}})), and its gate is an ordinary predicate. What makes it reliable is the same thing that makes tainting reliable — the decision is assembled from CPEX-owned state and handed to the router as a typed constraint, so the untrusted model cannot reword its way onto a backend that policy excluded. The clean split of ownership is the point: which backends exist and their health belong to the host; *which of them this request may use* is policy's to shape. +`restrict` is an effect like any other: it sequences with `require`, PDP calls, and `taint` (see [Effects]({{< relref "/docs/apl/effects" >}})), and its gate is an ordinary predicate. What makes it reliable is the same thing that makes tainting reliable: the decision is assembled from CPEX-owned state and handed to the router as a typed constraint, so the untrusted model cannot reword its way onto a backend that policy excluded. The clean split of ownership is the point: which backends exist and their health belong to the host; *which of them this request may use* is policy's to shape. diff --git a/docs/content/docs/apl/tainting.md b/docs/content/docs/apl/tainting.md index fd2025c1..076d6d39 100644 --- a/docs/content/docs/apl/tainting.md +++ b/docs/content/docs/apl/tainting.md @@ -61,7 +61,7 @@ global: endpoint: localhost:6379 ``` -Labels are scoped per subject. Two callers sharing a session identifier do not share taint: a label set while acting as one subject does not leak into another subject's decisions. With the Valkey store, labels survive a gateway restart, so a long-running session's information-flow history is not lost. +Labels are scoped per subject. Two callers sharing a session identifier do not share taint: a label set while acting as one subject does not leak into another subject's decisions. With the Valkey store, labels survive a gateway restart, so a long-running session's information-flow history is not lost. Labels written by CPEX 0.2.x are read back unchanged: the key layout is the same. ## How it connects to the pipeline diff --git a/docs/content/docs/assertions.md b/docs/content/docs/assertions.md new file mode 100644 index 00000000..c396021d --- /dev/null +++ b/docs/content/docs/assertions.md @@ -0,0 +1,178 @@ +--- +title: "Header Assertions" +weight: 58 +--- + +# Header Assertions + +CPEX validates tokens, maps claims into a typed identity, mints delegated credentials and accumulates labels. None of that reaches an upstream on its own. The `assertions:` block says which of it does, as request headers, and which response headers an upstream may not send back to a client. + +The block sits next to `authentication:` and holds two contracts: + +```yaml +global: + assertions: + request: # toward the upstream, on a pre-phase hook + headers: [...] + strip: [...] + response: # toward the client, on a post-phase hook + headers: [...] + strip: [...] +``` + +**Surfaces.** Assertions run inside the engine, so they work the same from Rust, Python and Go. The host passes the inbound headers on the HTTP extension (`http.request_headers`) and forwards what comes back on the modified extensions: `result.modified_extensions["http"]` in Python, `ModifiedExtensions` on the Go result. The `http:` route level is Rust only; see [HTTP Routing]({{< relref "/docs/http-routing" >}}). + +## Trust model + +Nothing that crosses either boundary is signed. An upstream that reads `x-auth-user-id` trusts it because it trusts the network path, not because it can verify anything. If nothing on that path stops a client from setting the header, this feature does not fix that. + +That is why removal is unconditional. Every header an entry targets is removed before injection, whether or not its source resolved. Otherwise a missing claim would leave the client's own value in place under a name the upstream reads as the gateway's. + +## An entry + +```yaml +headers: + - name: x-auth-user-id # the target header + from: subject.id # one source + on_missing: deny # or `omit`, the default + + - name: x-auth-scope + from: claim.projects + encode: csv # required: the source is a collection + + - name: x-auth-attributes # one JSON object, keys you choose + members: + roles: subject.roles + projects: claim.projects +``` + +`from:` and `members:` are alternatives; an entry with both fails to load. A `members:` entry always renders as a JSON object, so `encode:` on it fails to load too. + +`encode:` says how a value that is not a scalar becomes one header value. `json` renders every value as JSON, so a string renders quoted. `csv` joins an array with commas. With neither, a scalar renders bare and a structured value renders as compact JSON. A source that is always a collection, such as `subject.roles`, must declare one. + +Collections render sorted, and a members object's keys are sorted, so one identity produces the same header bytes on every request. A rendered value with CR, LF or NUL is dropped instead of emitted: claims come from a provider and can carry attacker input. + +## Sources + +```text +subject.id subject.type subject.roles subject.teams subject.permissions +claim. +client.client_id client.client_name client.trust_level client.roles +client.permissions client.teams client.authorized_scopes +client.authorized_audiences client.claim. +``` + +A claim name is taken whole, so a name with dots needs no escaping. A bare `claim` names the whole map and is refused. + +These can never be a source, in either direction: + +```text +raw_credentials.* the inbound bearer tokens, before validation +http.request_headers.* the client's own request headers +http.response_headers.* the upstream's own response headers +``` + +Copying a client header into a header the upstream trusts is the laundering this feature exists to stop. The request line and status (`http.method`, `http.path`, `http.host`, `http.scheme`, `http.status`) are refused as well, as paths outside the grammar. + +## Removal + +**Automatic removal.** Every header an entry targets is removed from the wire map before injection: the client's request in the request direction, the upstream's response in the response direction. It happens whether or not `strip:` exists and whether or not the source resolved. It also stops an upstream echoing an asserted header back to the client. + +**`strip:`.** Removes names no entry targets. It takes header names and trailing-glob patterns, matched case-insensitively. Every level's entries apply, so a lower level cannot undo an inherited removal by leaving it out. + +Removal and injection are one replacement of the header map, so a client value and an asserted one never exist side by side. + +## The two directions differ + +`request:` can withhold anything it does not name, because the engine produces every value it asserts. + +`response:` cannot. A response is the upstream's own output, and removing everything unnamed would drop `content-type`, `content-length`, caching, CORS, rate-limit and tracing headers. A response header nothing names reaches the client unchanged. `strip:` works the same in both directions. + +## Protocol floor + +Each direction has a fixed set of headers that `strip:` can never remove, because the recipient needs them to read the message. A `strip:` entry that would remove one fails at load and names the header. A `headers:` entry that targets a floor header is refused too. + +- **Request floor:** `host`, `content-type`, `content-length`, `transfer-encoding`. `authorization` is not in it, so you can strip the client's bearer before forwarding to an upstream that gets a delegated credential. +- **Response floor:** content negotiation and framing, caching and conditional requests, retry signalling, and the CORS response set. `set-cookie`, `server` and `x-powered-by` are not in it. + +`strip: ["*"]` fails to load in either direction. + +## Four levels that stack + +A contract can be written at any of four levels, and they accumulate the way `authentication:` does: + +1. `global:` +2. `global.defaults.:`, for `tool`, `resource`, `prompt`, `llm` or `http` +3. `groups.:` +4. a `routes[]` entry + +Resolution runs per direction, so a level can declare one direction and inherit the other. An entity default covers every request of that type, including one that matched no route. + +`headers:` unions by target name, compared case-insensitively. A repeated name takes the more specific level's entry whole. `strip:` unions. Two groups on one route asserting the same header in the same direction is a load error that names the route, direction, header and both groups. + +### Opting out + +```yaml +routes: + - tool: analytics.* + assertions: + request: + replace_inherited: true + headers: + - name: x-auth-user-id + from: subject.id +``` + +`replace_inherited: true` drops what accumulated above that level, for that direction only. It reaches `headers:` and `strip:` content you wrote and nothing else: automatic removal, the source exclusions and the floor still apply. On a route it is silent. On a group or an entity default it is reported at load, naming every route that lost inherited content. + +## When the contract applies + +The direction comes from the hook's phase. A pre-phase hook such as `cmf.tool_pre_invoke` applies `request:`, a post-phase hook such as `cmf.tool_post_invoke` applies `response:`, and other hooks apply neither. + +The contract runs after that phase's policy, so a rule still sees the client's headers unchanged. A rule that reads `http.request_headers.x-auth-user-id` is reading the client's value, not an asserted one. + +When a plugin denied the request, the request direction still removes what it names and injects nothing, and the response direction does not run. + +## Example from Python + +```yaml +global: + assertions: + request: + headers: + - name: x-auth-user-id + from: subject.id + strip: [authorization] +routes: + - tool: get_compensation + authorization: + pre_invocation: + - "allow" +``` + +```python +ext = { + "meta": {"entity_type": "tool", "entity_name": "get_compensation"}, + "security": {"subject": {"id": "alice"}}, + "http": {"request_headers": { + "authorization": "Bearer abc", + "x-auth-user-id": "mallory", + "accept": "json", + }}, +} +result = await engine.invoke_hook("cmf.tool_pre_invoke", payload, ext) +result.modified_extensions["http"]["request_headers"] +# {'x-auth-user-id': 'alice', 'accept': 'json'} +``` + +The client's forged `x-auth-user-id` is replaced, `authorization` is stripped, and `accept` passes through. + +## Reading the effective policy + +When a block is configured, the engine logs the whole boundary as one document at `info`. A Rust host can also get it from `cpex::praxis_policy_core::assertions::effective_policy` and expose it. It lists every header that can be emitted with its source and gating capability, the removal set, the exclusions and the floor, and the accumulated contract per route with the level each header came from. + +## 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. +- [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 new file mode 100644 index 00000000..42c85ee4 --- /dev/null +++ b/docs/content/docs/auditing.md @@ -0,0 +1,178 @@ +--- +title: "Auditing" +weight: 65 +--- + +# Auditing + +CPEX decides who may call which tool and what comes back. Auditing is the record of those decisions, and of the irreversible acts plugins perform to carry them out. + +There are two records, and they answer different questions: + +- A **decision** says how the pipeline ruled on one request: which plugins ran, what each did, and whether the request was allowed or blocked. +- An **effect** says what a plugin did to the outside world, such as minting a token at an IdP. Effects outlive the request and cannot be undone, so they are recorded separately and before the act. + +Both are off until you configure them. A plugin behaves the same either way: turning auditing off loses the record, not the behavior. + +## What each surface supports + +| Feature | Rust | Python | Go | +|---|---|---|---| +| 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` | +| 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 `praxis-policy-core`, 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. + +## What the engine promises + +A verdict that was reached is emitted once per invocation, at the engine's return points, and the record says what the caller was told. That includes requests the `assertions:` contract refused after the pipeline allowed them, and requests denied before the pipeline started because no route resolved. + +When an effect log is configured, every completed act has a durable record of its intent. The intent is written and `fsync`ed before the act. If it cannot be written, the act does not happen. After a crash, an intent with no outcome means the act may or may not have happened, and the external system it was performed against (the participant, such as the IdP) is the one to ask. + +Durable means written to the local filesystem and `fsync`ed, with the parent directory synced when the log is created. It does not mean replicated, and it is only as good as the storage under it. + +A sink only observes. The extensions it receives are filtered against the capabilities its own `plugins:` entry declares, the same as any plugin. It never gets the HTTP transport without `perform_http`, and it cannot write effects. + +## The effect log + +```yaml +engine_settings: + effect_log_path: /var/lib/cpex/effects.ndjson + effect_log_compaction_threshold: 1024 # optional; 0 disables auto-compaction +``` + +With a path set, intent is recorded before a plugin acts. The file holds one JSON record per line. Once an effect has both an intent and an outcome, the pair is compacted away, so the file tracks what is in flight. + +A crash can leave a final line cut short. Recovery discards that one line, since the act it guarded never proceeded. A bad line anywhere else is corruption, and recovery refuses to continue rather than drop a record of an irreversible act. + +A Rust host that wires plugins in code can call `PolicyEngine::set_effect_log` before `initialize`. That does not survive `load_config`, so a log that must outlive a reload belongs in the document. + +### Effect states + +| State | Meaning | +|---|---| +| `prepared` | Intent recorded. Nothing external has happened yet. | +| `confirmed` | The act completed. | +| `rejected` | The act provably did not happen. | +| `unknown` | Nobody can say. Reconcile it. | + +The OAuth delegator records every failed mint as `unknown`, including a 4xx with an OAuth error body: the transport cannot prove the authorization server sent it, and a timed-out call may still have landed. Only a check against an authoritative participant can record `rejected`. + +### Which modes may act + +Only `sequential` and `transform`. A plugin in any other mode that tries to perform an effect is refused with a violation naming the plugin, the mode and the key to change. Concurrent branches can be cancelled, `audit` is read-only, and `fire_and_forget` runs after the verdict, so an act there would happen for work the pipeline threw away. A plugin also cannot perform an effect on a clone of its extensions. + +### Recovery + +`initialize` sweeps the log once, before traffic. Completed effects are compacted away and the rest go to the reconciler. The default reconciler connects to nothing: it leaves each orphaned effect `unknown` and reports it at startup with its key and plugin. OAuth has no standard endpoint to ask whether a token was issued, so for the OAuth delegator that is usually the end of it. Its `idempotency_header:` setting names a vendor header for an IdP that supports one. + +A Rust host with an authoritative issuance ledger implements `EffectReconciler` and calls `PolicyEngine::recover_effects_with`. Calling `recover_effects` again while serving is safe: the engine skips keys it has an act open on. + +## Content provenance + +A decision record says which plugins ran, not whether they changed the request. A content hash answers that without the record holding either version: a keyed digest of the payload at entry and again at emission. Equal digests mean nothing changed the content. + +The digest is HMAC-SHA256 under a secret you declare, because a plain hash of short content can be guessed: + +```yaml +engine_settings: + capture_content_provenance: true + content_provenance_key: provenance_key +secrets: + providers: + local: { kind: file, base_dir: /etc/cpex } + values: + provenance_key: { provider: local, ref: provenance.key } +``` + +```sh +openssl rand -base64 48 > /etc/cpex/provenance.key +``` + +The key must be at least 32 bytes. Digests read `hmac-sha256::`, and two digests compare only when their key ids match. A `file` key rotates when the host refreshes secrets (`refresh_secrets` in Python, `RefreshSecrets` in Go, `PolicyEngine::refresh_secrets` in Rust); an `env` key rotates only with a restart. Engines writing into one audit stream should share a key. + +The Python and Go bindings install the `env`, `file` and `vault` secret providers for you. A Rust host installs a registry with `PolicyEngine::set_secret_providers` before `initialize`, for example `SecretProviderRegistry::with_builtin_backends()`. Without one, `initialize` fails naming the missing kind. + +For development, `content_provenance_key: unkeyed` records plain SHA-256 and warns at every load. Turning provenance on with no key fails to load. Only CMF `MessagePayload`s are digested. + +## Stream position + +```yaml +engine_settings: + audit_stream_namespace: gw-1 +``` + +Stream ids become `gw-1:decision` and `gw-1:effect`, so one collector can tell processes apart. Each record carries `epoch`, `stream_id`, `stream_seq` and `emission_seq`. Use `stream_seq` to detect loss within one `(epoch, stream_id)`, and `emission_seq` to merge the two streams in order. A smaller `stream_seq` under a larger `epoch` is a restart, not a gap. + +## Writing a sink in Rust + +Any plugin becomes a sink by overriding `Plugin::as_audit_handler`. `handle` receives every decision; `on_effect` receives every effect transition and defaults to ignoring them. + +```rust +use std::sync::Arc; + +use async_trait::async_trait; +use cpex::praxis_policy_core::audit::AuditHandler; +use cpex::praxis_policy_core::decision::{DecisionLog, Verdict}; +use cpex::praxis_policy_core::effect::EffectRecord; +use cpex::praxis_policy_core::hooks::payload::{Extensions, PluginPayload}; +use cpex::prelude::{Plugin, PluginConfig}; + +struct DecisionSink { + cfg: PluginConfig, +} + +#[async_trait] +impl AuditHandler for DecisionSink { + async fn handle( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + decisions: &DecisionLog, + ) { + let blocked = matches!(decisions.verdict(), Some(Verdict::Deny(_))); + eprintln!("{} blocked={blocked}", self.cfg.name); + } + + // Optional. Effects only reach a sink that wants them. + async fn on_effect(&self, _effect: &EffectRecord, _extensions: &Extensions) {} + + fn name(&self) -> &str { + &self.cfg.name + } +} + +#[async_trait] +impl Plugin for DecisionSink { + fn config(&self) -> &PluginConfig { + &self.cfg + } + + fn as_audit_handler(self: Arc) -> Option> { + Some(self) + } +} +``` + +Its factory returns a `PluginInstance` with no handlers. A sink fires for every hook family, so `payload` is not always a CMF `MessagePayload`: downcast and handle the other case. + +The engine awaits every sink before it returns, so sink latency is request latency. A sink that writes to the network should queue the record and return. Each call is bounded by `engine_settings.audit_timeout_milliseconds` (100 ms by default), and a sink that fails or panics is logged and skipped. + +Grant a sink exactly the capabilities for what it records (`read_subject`, `read_client`, `read_labels`, `read_delegated_tokens` and so on). A sink with none still emits a record per invocation, with the gated fields missing, and a reader cannot tell "no delegation happened" from "the sink could not see it". + +## Troubleshooting + +- `delegation.effects_not_permitted` or `effect_phase_not_permitted`: the plugin's mode cannot perform irreversible acts. Set `mode:` to `sequential` or `transform`. +- `effect_extensions_detached`: the plugin called `perform_effect` on a copy of its extensions. +- `delegation.effect_log_failed`: the intent could not be written. Check that the path is writable. The delegation is denied rather than minting a credential nothing accounts for. +- Unresolved effects at startup: expected with the default reconciler. Each names its key and plugin. + +## 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`). +- [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 5d643d4b..dea114dc 100644 --- a/docs/content/docs/builtins.md +++ b/docs/content/docs/builtins.md @@ -5,52 +5,131 @@ weight: 70 # Builtins -CPEX ships a set of builtin plugins, PDP resolvers, and a session store, each behind a Cargo feature. With a feature enabled, `cpex::install_builtins` registers its factory and APL can reference it by `kind`. +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 catalog +## The 0.3.0 catalog -| Kind | Type | Feature | Purpose | -|------|------|---------|---------| -| `identity/jwt` | identity | `jwt` | Resolve a subject from a verified JWT (see [Identity]({{< relref "/docs/apl/identity" >}})). | -| `delegator/oauth` | delegator | `oauth` | RFC 8693 token exchange (see [Delegation]({{< relref "/docs/apl/delegation" >}})). | -| `validator/pii-scan` | validator | `pii` | Detect and redact PII in content. | -| `audit/logger` | audit | `audit` | Append-only decision logging. | -| `cedar-direct` | PDP resolver | `cedar` | Evaluate Cedar policy (dialect `cedar`). | -| `cel` | PDP resolver | `cel` | Evaluate CEL expressions (dialect `cel`). | -| `valkey` | session store | `valkey` | Persist taint labels across processes (see [Session Tainting]({{< relref "/docs/apl/tainting" >}})). | +| Kind | Type | Purpose | +|------|------|---------| +| `identity/jwt` | identity | Resolve a subject from a verified JWT, with configurable claim mapping (see [Identity]({{< relref "/docs/apl/identity" >}})). | +| `identity/api-key` | identity | Resolve an opaque API key against a file or HTTP directory (see [Identity & Delegation]({{< relref "/docs/identity-delegation#recipe-7-an-opaque-api-key-resolved-against-a-directory" >}})). | +| `delegator/oauth` | delegator | RFC 8693 token exchange and `client_credentials`, with optional caching (see [Delegation]({{< relref "/docs/apl/delegation" >}})). | +| `delegator/vault` | delegator | Retrieve downstream credentials from Vault KV v2 for a user, client or workload. Authorize before delegation. | +| `elicitation/ciba` | elicitation | OIDC CIBA human approval (see [Elicitation]({{< relref "/docs/apl/elicitation" >}})). | +| `cedar-direct` | PDP resolver | Evaluate Cedar policy (dialect `cedar`). | +| `cel` | PDP resolver | Evaluate CEL expressions (dialect `cel`). | +| `opa` | PDP resolver | Evaluate Rego, embedded, no sidecar (dialect `opa`). | +| `valkey` | session store | Persist taint labels across processes (see [Session Tainting]({{< relref "/docs/apl/tainting" >}})). | +| `env`, `file` | secret providers | Read `secrets:` values from the environment or from files. | +| `vault` | secret provider | Read `secrets:` values from Vault KV v2 over the host's HTTP transport. | -The default session store is in-process memory; no feature or `kind` is needed for it. +The default session store is in-process memory; no `kind` is needed for it, but its labels do not survive a restart or reach a second replica. -## Cargo features +CPEX also ships one plugin of its own: the Biscuit delegator (`cpex-plugin-delegator-biscuit`), which a Rust host registers in code. PPE's experimental `quota` plugin is not part of the CPEX build. -```bash -# nothing bundled (engine only) -cargo add cpex +## How each surface installs them -# the common in-process set: jwt, oauth, pii, audit, cedar, cel -cargo add cpex --features builtins +| Surface | Call | Installs | +|---|---|---| +| Rust | `cpex::install_builtins(&engine)` | plugin, PDP and session store factories, and the APL visitor | +| Rust | `cpex::install_default_http_transport(&engine)` | the bundled HTTP transport (private destinations refused) | +| Rust | `engine.set_secret_providers(cpex::registry_with_vault(transport))` | `env`, `file` and `vault` | +| Python | `PolicyEngine(path, *, allow_private_destinations=False)` | all of the above, then loads the config | +| Go | `engine.EnableAPL(opts...)` | all of the above | +| C ABI | `cpex_apl_install(handle, flags)` | all of the above | -# everything, including the Valkey session store -cargo add cpex --features full +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. -# a granular subset -cargo add cpex --features "jwt,cedar,pii" +## The HTTP transport + +The engine performs no outbound HTTP of its own. A host installs an `HttpTransport` and the builtins borrow it, so a process embedding CPEX keeps one connection pool, one TLS trust store and one egress path. JWKS fetches, OAuth token exchange, CIBA, API-key HTTP directories and Vault all go through it. + +- A Rust host with its own client installs it with `engine.set_http_transport(...)`. A host with none calls `cpex::install_default_http_transport(&engine)`. Install it before `initialize()`. It is set-once: a second install is ignored. +- The Python, Go and C ABI surfaces build one transport per engine and pass the same instance to Vault. + +Two mistakes fail `initialize()` with distinct messages: no transport installed (a wiring problem in the host program), and a transport installed but the plugin does not declare `perform_http` (fix the plugin's `capabilities:` list). + +### Private destinations + +The bundled transport refuses private destinations by default: loopback, RFC 1918 ranges, link-local addresses (including cloud metadata at `169.254.169.254`) and CGNAT. The check applies to the address that would be dialed, so a hostname that resolves only to such an address is refused too. Each surface has one opt-in, set in host code; no policy document can turn it on: + +| Surface | Opt-in | +|---|---| +| Rust | `engine.set_http_transport(Arc::new(HyperTransport::new().with_allow_private_destinations()))` | +| Python | `PolicyEngine(path, allow_private_destinations=True)` | +| Go | `engine.EnableAPL(cpex.WithPrivateDestinations())` | +| C ABI | `cpex_apl_install(handle, CPEX_APL_ALLOW_PRIVATE_DESTINATIONS)` | + +> **The opt-in is engine-wide.** It permits loopback, RFC 1918, link-local (including cloud metadata) and CGNAT for every outbound builtin, not for one plugin or one host. Anyone who can edit a plugin URL in the policy can then make the engine call internal services or the metadata endpoint. The tutorial and the demos turn it on to reach a local IdP. Do not copy that into production. To reach an in-cluster IdP or Vault in production, install your own transport with explicit egress rules. + +A refused call shows up as `delegation.egress_denied` (OAuth), `elicitation.egress_denied` (CIBA), or `auth.jwks_unavailable` for a JWKS fetch, which looks like an unreachable IdP. A refused Vault read fails `initialize()`. + +## Not builtins: the PII scanner and audit logger + +`validator/pii-scan` and `audit/logger` were bundled in 0.2.x. They are not in 0.3.0 on any surface, and a config that names either fails to load with an unknown-kind error. PPE keeps both as reference plugins: the scanner is plain regexes with no checksum validation, and the logger writes to stderr, so neither is something to ship as a supported control. + +What to use instead: + +- Redaction: an APL field pipeline, `ssn: "str | redact(!perm.view_ssn)"`, or `mask(N)`. +- A content deny: a `cel:` step or an APL `deny` rule over the arguments. +- Detection you trust: a host plugin backed by a real detector, called with `run(...)`. +- Audit: a host plugin that a `run(...)` step names, like the tutorial's `tutorial/audit-line`. See [Auditing]({{< relref "/docs/auditing" >}}) for what each surface supports. + +### Reference plugins in a Rust host + +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 depend on `praxis-policy-core = "=0.5.0"` from crates.io, the same version `cpex` pins. +3. Register its factory before loading the config: + +```rust +use std::sync::Arc; +use cpex::PolicyEngine; + +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +engine.register_factory( + praxis_policy_plugin_pii_scanner::KIND, // "validator/pii-scan" + Box::new(praxis_policy_plugin_pii_scanner::PiiScannerFactory), +); +engine.register_factory( + praxis_policy_plugin_audit_logger::KIND, // "audit/logger" + Box::new(praxis_policy_plugin_audit_logger::AuditLoggerFactory), +); +engine.load_config_yaml(yaml)?; ``` -| Feature | Pulls in | -|---------|----------| -| `builtins` | the six default builtins (jwt, oauth, pii, audit, cedar, cel) | -| `full` | `builtins` plus `valkey` | -| `jwt` | `identity/jwt` | -| `oauth` | `delegator/oauth` | -| `pii` | `validator/pii-scan` | -| `audit` | `audit/logger` | -| `cedar` | `cedar-direct` | -| `cel` | `cel` | -| `valkey` | `valkey` session store | +Do not pull them in as a git dependency on the PPE repository. Cargo would build a second copy of `praxis-policy-core` from git, and its `Plugin` and `PluginFactory` types would not match the ones `cpex` links from crates.io, so the factory would not register. -The default build (`cpex = "0.2"` with no features) is the engine alone, so a host that only needs the runtime and its own plugins compiles nothing extra. +You may register them under your own `kind` strings. Keep the plugin `name` your policy uses, so `run(pii-scan)` and `run(audit-log)` steps stay unchanged. + +The Python and Go builds do not include these plugins and cannot load them: neither binding has a way to register a Rust factory from the host language at runtime. A Go host can link its own Rust FFI crate that registers them (see the [Go package README](https://github.com/contextforge-org/cpex/tree/main/go/cpex#writing-plugins-rust-for-go-callers)). ## Referencing builtins from APL -A registered builtin is referenced by `kind` in the config. Plugins declare their hooks and capabilities; PDP resolvers are registered under `global.pdp`; the session store under `global.session_store`. See [Configuration]({{< relref "/docs/configuration" >}}) for the full structure. +A registered builtin is referenced by `kind` in the config. Plugins declare their hooks and capabilities; PDP resolvers go under `global.pdp`; the session store under `global.session_store`; secret providers under `secrets.providers`. + +```yaml +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: user + header: X-User-Token + trusted_issuers: [ ... ] + +global: + pdp: + - kind: opa + policy_text: | + package cpex + default allow := false + session_store: + kind: valkey + endpoint: localhost:6379 +``` + +See [Configuration]({{< relref "/docs/configuration" >}}) for the full structure. diff --git a/docs/content/docs/cmf-extensions.md b/docs/content/docs/cmf-extensions.md new file mode 100644 index 00000000..2b7d2299 --- /dev/null +++ b/docs/content/docs/cmf-extensions.md @@ -0,0 +1,220 @@ +--- +title: "CMF Extensions" +weight: 42 +--- + +# CMF Extensions and the Attribute Bag + +Policy reads a flat attribute bag. The engine fills it by walking each present slot on the request's `Extensions` into dotted keys. Plugins that are handed a typed slot still see the original struct. This page is the contract for what the bridge writes, how collections relate to the flattened booleans, and what each decision point does with a missing key. + +[Extensions & Capability-Gating]({{< relref "/docs/extensions" >}}) covers which capability unlocks each slot. This page covers the keys. + +Twelve slots are bridged. Three slots never enter the bag: `raw_credentials` (credentials stay out of policy), `candidate_constraint` (a routing constraint, not an attribute) and `llm_request` (it reaches PDPs as structured JSON instead). + +## Setting slots from each surface + +The host supplies the extensions on every invocation. + +- **Rust:** build `cpex::praxis_policy_core::extensions::Extensions` directly. Every slot is available. +- **Python:** pass a dict as the `extensions` argument of `invoke_hook`. It is deserialized into the same `Extensions` type, so every slot on this page can be set, for example `{"meta": {...}, "security": {"subject": {...}}, "http": {"request_headers": {...}}}`. +- **Go:** fill `cpex.Extensions`. It declares a subset of the fields. It has no `security.client`, `caller_workload` or `this_workload`, no HTTP request line or status (`method`, `path`, `host`, `scheme`, `status`), and `SubjectExtension.Claims` holds strings only, so nested claims cannot be set from Go. Keys that depend on those fields are never written for a Go request unless a plugin on the engine side sets them, such as an identity resolver. + +## Which slots a route's policy sees + +A route's rules read the bag through the route's own handler, and that handler is capability-gated like a plugin. It always holds `read_subject`, `read_roles`, `read_permissions`, `read_teams`, `read_claims`, `read_labels`, `read_delegation`, `read_agent`, `read_meta` and `read_headers`, plus every capability declared by a plugin the route references. Other slots are filtered out. For example, a host that sets `security.client` on a route that references no plugin with `read_client` gets a deny from `require(client.client_id == "app-1")`, because `client.*` is never written for that route. A Rust host can change the base set with `AplOptions::base_capabilities` and `register_apl`; `install_builtins` and the bindings use the default. + +## Absent values + +The rule is per attribute type, and it applies only inside a present slot. An absent slot writes nothing for its namespace. + +| Type | When the field is empty or unset | Why | +|---|---|---| +| String set | Present and empty. Membership is false. | CEL treats a missing key as an error, so `!("banned" in subject.roles)` would deny every subject with no roles. | +| Bool field (`delegation.delegated`) | Present, including `false`. | The field is not optional. | +| Flattened bool (`role.hr`) | Omitted. Presence means true. Only membership names without dots get one. | APL reads a missing flattened bool as false. In CEL, do not guard with `has(role.hr)`: if no `role.*` key exists, that is an evaluation error. Use `"hr" in subject.roles`. | +| Derived bool (`authenticated`) | Omitted unless `subject.id` is set. | `!authenticated` is true and `require(authenticated)` denies. In CEL, use `has(subject.id)` as the guard. | +| String | Omitted when unset. A non-optional string (`client.client_id`) is always written, even if empty. | Empty and missing are different questions. | +| Int, Float | Omitted when unset (`http.status`, `agent.turn`, `completion.latency_ms`). Non-optional ones (`delegation.depth`, `delegation.age_seconds`) are always written. | A missing status is not zero: `http.status >= 500` and `http.status == 0` would both lie. | +| JSON object or claims | No parent key. Each scalar or scalar-array child is written under a dotted path. `{}`, `null`, and arrays holding containers write nothing. | The bag has no map type. | + +## Collections and flattened booleans + +| Collection | Flattened form | +|---|---| +| `subject.roles` | `role. = true` | +| `subject.permissions` | `perm. = true` | +| `subject.teams` | `team. = true` | +| `client.roles` | `client.role. = true` | +| `client.permissions` | `client.perm. = true` | + +The set is the primary form. Only these five get flattened aliases. Other sets are set-only: `client.teams`, `client.authorized_scopes`, `client.authorized_audiences`, `caller_workload.selectors`, `this_workload.selectors`, `security.labels`, `agent.conversation.topics`, `meta.tags`, `llm.capabilities`. So `require(tag.pii)` is always false; use `meta.tags contains "pii"`. + +A dotted membership name such as `admin.readonly` stays in `subject.roles` but gets no `role.admin.readonly` key, because CEL would read that key as nested maps. Test dotted names through the set. + +Prefer the set for membership in every dialect: `subject.roles contains "hr"` in APL, `"hr" in subject.roles` in CEL, `"hr" in input.subject.roles` in OPA. The set is present whenever the subject is, so all decision points agree on empty. + +Cedar reads `principal.roles`, `principal.permissions` and `principal.teams` from the sets. It does not surface `client.*`, `http.*` or other slots as principal attributes. + +## Claims + +There is no `subject.claims` key. Each claim goes through the same JSON flattener as `custom.*` and `args.*`: + +- a scalar lands at `claim.` with its type kept +- a scalar array, empty included, lands as a string set +- `{}`, `null`, and an array holding a container write nothing +- a nested object writes only its children (`claim.realm_access.roles`), never the parent + +Client claims use the same shape under `client.claim.`. That covers `claim.tenant == "acme"` and `claim.realm_access.roles contains "admin"`, but not "any claim at all". Plugins read the typed `SubjectExtension.claims`. + +## Missing keys per decision point + +| Engine | Missing key | Empty set | +|---|---|---| +| APL | False for presence, equality, membership and order. Every negated form is true: `!=`, `!key`, `!(...)`, `not in`. | `contains` / `in` is false | +| CEL | Evaluation error, which the default `on_error: deny` turns into a deny that reports a key error | `in` is false | +| cedar-direct | Empty `roles`, `permissions`, `teams` and `claims` exist on the principal. No `subject.id` is a dispatch error. A missing `subject.type` defaults to `User`; the bridge writes lowercase types. | `contains` is false | +| OPA | Undefined. Without `default allow := false`, the query is a default deny. `not` of undefined is true. | `in` is false | + +`require` denies when its predicate is false, so on omitted keys: + +| Rule | APL | Other engines | +|---|---|---| +| `require(claim.tenant == "acme")` | Deny | Deny | +| `require(subject.roles contains "hr")` | Deny | Deny | +| `require(claim.tenant != "acme")` | Allow | Deny | +| `require(subject.type not in blocked_types)` | Allow | CEL and cedar-direct deny; OPA allows | + +To keep a denylist closed when the key is missing, write `require(exists(claim.tenant) & claim.tenant != "acme")`. + +## The twelve slots + +Keys marked **always** are written whenever the slot (or the noted sub-record) is present. The rest are omitted when unset. + +### `security` + +Subject (when present): + +| Key | Type | When | +|---|---|---| +| `subject.id` | String | set | +| `subject.type` | String (`user`, `agent`, `service`, `system`) | set | +| `subject.roles`, `subject.permissions`, `subject.teams` | String set | always | +| `role.`, `perm.`, `team.` | Bool | each member without a dot | +| `claim.` | flattened JSON | each claim | +| `authenticated` | Bool | `subject.id` is set | + +Client (when present): + +| Key | Type | When | +|---|---|---| +| `client.client_id` | String | always | +| `client.client_name` | String | set | +| `client.trust_level` | String (`first_party`, `third_party`, `internal`, custom, `unknown`) | always | +| `client.roles`, `client.permissions`, `client.teams` | String set | always | +| `client.role.`, `client.perm.` | Bool | each member without a dot | +| `client.authorized_scopes`, `client.authorized_audiences` | String set | always | +| `client.claim.` | flattened JSON | each claim | + +Workload (`caller_workload` and `this_workload`, same shape): + +| Key | Type | When | +|---|---|---| +| `.spiffe_id`, `.trust_domain`, `.attestor`, `.client_id` | String | set | +| `.selectors` | String set | always | + +Other, whenever the security slot is present: `auth_method` (String, set), `security.labels` (String set, always), `security.classification` (String, set). `security.objects` and `security.data` are not in the bag; plugins read them from the typed slot. + +### `delegation` + +| Key | Type | When | +|---|---|---| +| `delegation.depth` | Int | always | +| `delegation.delegated`, `delegated` | Bool | always | +| `delegation.origin_subject_id`, `delegation.actor_subject_id` | String | set | +| `delegation.age_seconds` | Float | always | + +Per-hop scopes, audience and strategy stay on the typed chain. + +### `agent` + +| Key | Type | When | +|---|---|---| +| `agent.input`, `agent.session_id`, `agent.conversation_id`, `agent.agent_id`, `agent.parent_agent_id` | String | set | +| `agent.turn` | Int | set | +| `agent.conversation.summary` | String | conversation present and summary set | +| `agent.conversation.topics` | String set | conversation present | + +### `meta` + +| Key | Type | When | +|---|---|---| +| `meta.entity_type`, `meta.entity_name`, `meta.scope` | String | set | +| `meta.tags` | String set | always | +| `meta.properties.` | String | each entry | + +### `request` + +`request.environment`, `request.request_id`, `request.timestamp`, `request.trace_id`, `request.span_id`: String, when set. + +### `http` + +| Key | Type | When | +|---|---|---| +| `http.method`, `http.path`, `http.host`, `http.scheme` | String | 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`. + +### `mcp` + +| Key | Type | When | +|---|---|---| +| `mcp.tool.name` | String | tool present | +| `mcp.tool.title`, `mcp.tool.description`, `mcp.tool.server_id`, `mcp.tool.namespace` | String | set | +| `mcp.resource.uri` | String | resource present | +| `mcp.resource.name`, `mcp.resource.description`, `mcp.resource.mime_type`, `mcp.resource.server_id` | String | set | +| `mcp.prompt.name` | String | prompt present | +| `mcp.prompt.description`, `mcp.prompt.server_id` | String | set | + +Schemas and annotations are not flattened. + +### `completion` + +| Key | Type | When | +|---|---|---| +| `completion.stop_reason` | String | set | +| `completion.tokens.input`, `completion.tokens.output`, `completion.tokens.total` | Int | tokens present | +| `completion.model`, `completion.raw_format`, `completion.created_at` | String | set | +| `completion.latency_ms` | Int | set | + +### `provenance` + +`provenance.source`, `provenance.message_id`, `provenance.parent_id`: String, when set. + +### `framework` + +`framework.framework`, `framework.framework_version`, `framework.node_id`, `framework.graph_id` (String, set) and `framework.metadata.` (flattened JSON). + +### `custom` + +`custom.`: flattened JSON for each entry. + +## Keys that do not come from slots + +| Source | Keys | +|---|---| +| Request arguments | `args.`. A top-level scalar is `args`; a top-level scalar array is `args` as a string set. | +| Upstream result | The same shapes under `result` and `result.`. | +| Static `data:` tree | `data` and `data.`. | +| Route | `route.key` | + +On `tool:` routes, OPA and CEL do not read the flattened `args.*` keys. They get the arguments as native JSON, so arrays of objects, order, numbers and `null` survive. Cedar gets the same JSON, sanitized, as `context.args`. APL still reads the flattened keys. See [PDP]({{< relref "/docs/apl/pdp" >}}). + +## 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. +- [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/cmf.md b/docs/content/docs/cmf.md index 9001c4e1..453eeb4c 100644 --- a/docs/content/docs/cmf.md +++ b/docs/content/docs/cmf.md @@ -33,10 +33,12 @@ CMF operations run at CMF hooks, which parallel the typed hooks but carry a `Mes |------|-------| | `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` | around a tool call | | `cmf.llm_input` / `cmf.llm_output` | around an inference call | -| `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch` | around a prompt fetch | +| `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` | around a prompt fetch | | `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` | around a resource fetch | -An APL route's `policy` phase runs at the relevant `*_pre_*` hook and its `result` phase at the `*_post_*` hook. Writing a guardrail against the CMF hook means it covers every operation type that maps to it, rather than one protocol's payload. +At the relevant pre-operation hook, a route evaluates `args` and `authorization.pre_invocation`. At the post-operation hook, it evaluates `result` and `authorization.post_invocation`. Writing a guardrail against the CMF hook means it covers every operation type that maps to it, rather than one protocol's payload. + +The prompt hooks were `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch` in 0.2.x; the old names fail to load. Generic HTTP requests use their own `http.request` / `http.response` hooks and carry no message. ## How it connects to policy diff --git a/docs/content/docs/configuration.md b/docs/content/docs/configuration.md index f9da8899..cd894d62 100644 --- a/docs/content/docs/configuration.md +++ b/docs/content/docs/configuration.md @@ -5,27 +5,39 @@ weight: 80 # Configuration -A CPEX config is a single document that declares the plugins and PDP resolvers available, the global settings, and the APL routes. The runtime loads it and the APL visitor wires routes to hooks. +A CPEX config is one YAML document. It declares the plugins the process can reach, the cross-cutting wiring, and the policy that decides which of them run for a given operation. The engine loads it and the APL visitor wires routes to hooks. + +Every key is known. A key the document model does not name fails the load, and where a key was renamed the error names its replacement. If your config loads, every key in it does something. Coming from 0.2.x? The [migration guide]({{< relref "/docs/migration-0.3#config-keys" >}}) maps every removed key. ## Shape ```yaml -plugins: # the plugins available to policy, by kind +engine_settings: # dispatch mode and runtime limits +global: # cross-cutting wiring, defaults, and policy +plugins: # the plugins available, by kind +groups: # reusable policy bundles routes opt into +routes: # policy, one entry per operation +secrets: # secret providers, and the values bound to them +``` + +Anything else is a load error. All six are optional. + +```yaml +plugins: - name: ... kind: ... hooks: [...] capabilities: [...] config: { ... } -global: # cross-cutting resolvers and stores - apl: - pdp: - - kind: ... - session_store: - kind: ... +global: + pdp: + - kind: ... + session_store: + kind: ... -routes: # APL policy, a list of operations - - tool: # or resource: / prompt: / llm: +routes: + - tool: # or resource: / prompt: / llm: / http: authentication: [ ... ] # identity-resolution plugins args: { ... } authorization: @@ -34,19 +46,59 @@ routes: # APL policy, a list of operations result: { ... } ``` +## Dispatch modes + +`engine_settings.dispatch` picks between two mutually exclusive models. A document is legal in one of them only. + +| Mode | What selects a plugin | Rejects | +|---|---|---| +| `policy` (default) | a `run(name)` step, an `authentication:` list, a `delegate(...)` call, or an elicitation verb | a per-plugin `conditions:` and `priority:`, and a list-form `plugins:` | +| `hooks` | each plugin's own `hooks:` and `conditions:` | `routes:`, `groups:`, `global:`, `global.defaults:` | + +Under `dispatch: policy` a plugin runs only where a policy reaches it. Two consequences: + +- The load reports a declared plugin no policy reaches, by name, and warns (`plugin_narrowed_by_policy`) when a plugin is reached on fewer hooks than it declares. +- Identity plugins run only on routes that list them under `authentication:`, at route, group, entity-default or global scope. A route that reads `subject.*` or `authenticated` must list one. + +| Setting | Default | Meaning | +|---|---|---| +| `dispatch` | `policy` | which model above | +| `plugin_timeout` | `30` | per-plugin timeout, in seconds | +| `audit_timeout_milliseconds` | `100` | maximum time per audit sink callback | +| `short_circuit_on_deny` | `true` | stop a hook's remaining plugins once one denies | +| `route_cache_max_entries` | `10000` | dispatch-plan cache size | + +`engine_settings:` also accepts `effect_log_path`, `effect_log_compaction_threshold`, `capture_content_provenance`, `content_provenance_key` and `audit_stream_namespace`, which configure durable effects and audit. + +## What each scope accepts + +A key outside its scope's set is a load error naming the scope and the set. + +| Scope | Keys | +|---|---| +| the document | `global`, `plugins`, `groups`, `routes`, `secrets`, `engine_settings` | +| `global:` | `defaults`, `authentication`, `assertions`, `response`, `authorization`, `pdp`, `session_store`, `attribute_files` | +| `global.defaults.:` | `description`, `metadata`, `plugins`, `authentication`, `assertions`, `response`, `authorization`, `args`, `result` | +| `groups.:` | the same set as `global.defaults.:` | +| `routes[]` | `tool`, `resource`, `prompt`, `llm`, `http`, `meta`, `groups`, `plugins`, `authentication`, `assertions`, `response`, `authorization`, `args`, `result` | +| an `authentication:` block | `steps`, `replace_inherited` | +| an `authentication:` step | `name`, `config` | + +`pdp:`, `session_store:` and `attribute_files:` are `global:` only. `args:` and `result:` are not accepted under `global:`: write field pipelines on the route or on `global.defaults.:`. Under `dispatch: policy`, `plugins:` is a map of per-plugin overrides; the list form is a load error. + ## Plugins -Each plugin entry declares how it is identified, where it runs, and what it may see: +Each entry declares how a plugin is identified, where it runs, and what it may see: | Field | Meaning | |-------|---------| -| `name` | Instance name, referenced from APL (`plugin(name)`, `delegate(name, ...)`). | -| `kind` | Which plugin implementation (for example `identity/jwt`, `audit/logger`). | +| `name` | Instance name, referenced from policy as `run(name)` or `delegate(name, ...)`. | +| `kind` | Which implementation, for example `identity/jwt`. | | `hooks` | The hook points it registers on. | -| `mode` | Execution mode (see [Plugins & Pipeline]({{< relref "/docs/pipeline" >}})). | -| `priority` | Order within a hook; lower runs first. | -| `on_error` | `fail`, `ignore`, or `disable`. | -| `capabilities` | Declared context access (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})). | +| `mode` | `sequential` (default), `transform`, `audit`, `concurrent`, `fire_and_forget`, or `disabled`. See [Plugins & Pipeline]({{< relref "/docs/pipeline" >}}). | +| `priority` | Order within a hook, lower first. `dispatch: hooks` only. | +| `on_error` | `fail` (default), `ignore`, or `disable`. | +| `capabilities` | Declared context access. See [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}}). | | `config` | Plugin-specific settings. | ```yaml @@ -54,6 +106,7 @@ plugins: - name: jwt-user kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: user header: X-User-Token @@ -65,55 +118,63 @@ plugins: url: "https://idp.example.com/realms/agents/protocol/openid-connect/certs" - name: audit-log - kind: audit/logger + kind: acme/audit # a host plugin the embedding program registers hooks: [cmf.tool_pre_invoke] - priority: 90 capabilities: [read_subject, read_client, read_delegation] ``` -## Global +A plugin that reaches outside the process must declare `perform_http`: an `identity/jwt` with a `jwks_url`, every `delegator/oauth`, every `elicitation/ciba`, and an `identity/api-key` with an HTTP directory. Withholding it stops the call rather than degrading it, because a plugin that quietly skipped its IdP call would fail open. The host must also install an HTTP transport; see [Builtins]({{< relref "/docs/builtins#the-http-transport" >}}). -`global.apl.pdp` registers PDP resolvers; `global.apl.session_store` selects where taint labels live (absent it, the in-process memory store is used). +## Global ```yaml global: - apl: - pdp: - - kind: cedar-direct - policy_text: | - permit(principal, action == Action::"read", resource is Repo) - when { principal.roles.contains("security") }; - session_store: - kind: valkey - endpoint: localhost:6379 + pdp: + - kind: cedar-direct + policy_text: | + permit(principal, action == Action::"read", resource is Repo) + when { principal.roles.contains("security") }; + session_store: + kind: valkey + endpoint: localhost:6379 + attribute_files: + - attributes/tenants.yaml ``` +`pdp:` is a sequence, one block per decision point. An entry may carry an optional `cache:` block with a positive `ttl_seconds` and `max_entries`; omitting it leaves evaluation uncached, and an external PDP's policy can go stale until the TTL expires. `session_store:` selects where taint labels live; without it, labels stay in an in-process memory store and do not survive a restart or reach a second replica. `attribute_files:` loads the operator-maintained tree policy reads under `data.*` (see [Static Attributes]({{< relref "/docs/apl/attributes" >}})). + ## Routes -Routes carry the APL policy. The runtime loads routes as a **list**, one entry per operation, matched by `tool:` (or `resource:` / `prompt:` / `llm:`): +Routes carry the APL policy, one entry per operation, selected by `tool:`, `resource:`, `prompt:`, `llm:` or `http:`: ```yaml +plugins: + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [perform_http] + config: { ... } + routes: - tool: get_compensation + authentication: [jwt-user] authorization: pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - "taint(secret, session)" - - "plugin(audit-log)" + - "run(audit-log)" result: ssn: "str | redact(!perm.view_ssn)" ``` -Within a route, the two authorization phases may be written nested under `authorization:` (as above) or flat, as `pre_invocation:` / `post_invocation:` directly on the route; the two are equivalent. +Both phase lists nest under `authorization:`. Writing `pre_invocation:` or `post_invocation:` directly on a route is a load error, and an `authorization:` block that contributes no step is rejected. An open route writes `pre_invocation: ["allow"]`. `args:` and `result:` stay directly on the route. See [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. -> **Runtime config vs. apl-core.** The `apl-core` crate also accepts a map-keyed `routes:` form (keyed by route name) through its standalone `compile_config` entry point, used mainly in tests. The runtime host path (`load_config_yaml`) does not: it parses the list form shown here. Write the list form for anything you load into a running CPEX. See [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. - -Route-level overrides can adjust a plugin's `capabilities` or `config` for a specific operation, so a scanner can be granted `read_labels` on one sensitive route without widening its access everywhere. +A `plugins:` map on a route adjusts a plugin's `capabilities`, `config` or `on_error` for that operation, so a scanner can be granted `read_labels` on one sensitive route without widening its access everywhere. ## Groups -A **group** is a named, reusable bundle of policy — `authentication:` steps, `authorization:` steps, and/or `plugins` — that routes opt into. It is the middle layer between global defaults and per-route policy. Define groups at the top-level `groups:` section, keyed by name: +A **group** is a named, reusable bundle of policy (`authentication:` steps, `authorization:` steps, field pipelines) that routes opt into. It is the middle layer between global defaults and per-route policy. Define groups at the top-level `groups:` section, keyed by name: ```yaml groups: @@ -124,7 +185,7 @@ groups: - "require(role.hr)" # + policy for this group ``` -A route joins a group with the `groups:` field — a bare string or a list: +A route joins a group with the `groups:` field, a bare string or a list: ```yaml routes: @@ -132,28 +193,89 @@ routes: groups: hr-tools # or: groups: [hr-tools, pii] ``` -`groups:` is **sugar over tags**: `meta: { tags: [hr-tools] }` is exactly equivalent, and a host-injected runtime tag joins a group the same way when its name matches a group. Tags remain the substrate — `groups:` just names the common "join this bundle" case as a first-class field. +`groups:` is sugar over tags: `meta: { tags: [hr-tools] }` is exactly equivalent, and a host-injected runtime tag joins a group the same way when its name matches a group. A route that joins a group inherits both its `authentication:` and its `authorization:`. A group no route reaches fails the load. -## Global settings and defaults +## Requests with no entity -Every top-level section (`plugins`, `global`, `routes`, `plugin_dirs`, `plugin_settings`) is optional. `plugin_settings` controls runtime behavior: +The host selects a route from the request's entity metadata, `meta.entity_type` and `meta.entity_name`. A request that carries none resolves no route. It is denied with the violation code `unidentified_request` and a 400-class status, distinct from a policy's own deny because no rule was reached. A config that declares no policy at all passes such a request through. -| Setting | Default | Meaning | -|---------|---------|---------| -| `routing_enabled` | `false` | `false`: each plugin self-selects via its own `conditions:`. `true`: `routes:` / `global:` drive selection and per-plugin `conditions:` are ignored. Route-based configs set this `true`. | -| `plugin_timeout` | `30` | Per-plugin timeout, in seconds. | -| `short_circuit_on_deny` | `true` | Stop a hook's remaining plugins once one denies. | -| `fail_on_plugin_error` | `false` | Whether a plugin error fails the request (see also per-plugin `on_error`). | -| `parallel_execution_within_band` | `false` | Run same-priority plugins concurrently. | -| `route_cache_max_entries` | `10000` | Dispatch-plan cache size. | +## Secrets and key material -Per-plugin fields default to `mode: sequential`, `on_error: fail`, and a `priority` that orders plugins within a hook (lower runs first). +There is no `${ENV}` substitution in config fields. A secret has one shape, and the config names where it comes from rather than carrying it. -## Secrets and key material +### The `secrets:` block + +A **provider** reads one backend. A **value** binds a name to one provider and one reference. Everything downstream names the value, never the provider and never a raw reference, so the set of secrets the process can reach is the `values:` map and nothing else. + +```yaml +secrets: + providers: + local: { kind: file, base_dir: /etc/cpex } + shell: { kind: env } + values: + upstream_api_key: { provider: local, ref: upstream.key } + session_password: { provider: shell, ref: VALKEY_PASSWORD } +``` + +| kind | `ref` is | notes | +|---|---|---| +| `file` | a path | With `base_dir`, a reference must be relative and may not contain `..`. One trailing newline is stripped. An empty file is an error. | +| `env` | a variable name | Fixed at process start, so these never rotate without a restart. | +| `vault` | `/#` | Vault KV v2 over the host's HTTP transport. Kubernetes or AppRole auth. | + +`file` covers Kubernetes Secret volumes, CSI-projected secrets and a Vault Agent sidecar writing to disk, so many deployments need no network backend. + +Which providers are installed depends on the surface. The Python and Go bindings, and `cpex_apl_install` in the C ABI, install `env`, `file` and `vault`, with Vault on the same transport as the builtins. A Rust host installs them itself before `initialize()`: + +```rust +use std::sync::Arc; +use cpex::praxis_policy_core::http::HttpTransport; +use cpex::{HyperTransport, PolicyEngine}; + +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +let transport: Arc = Arc::new(HyperTransport::new()); +engine.set_http_transport(Arc::clone(&transport)); +engine.set_secret_providers(cpex::registry_with_vault(transport)); +``` + +### Vault + +```yaml +secrets: + providers: + vault-prod: + kind: vault + address: https://vault.example.com:8200 + auth: + method: kubernetes + role: cpex + values: + session_password: { provider: vault-prod, ref: secret/cpex#password } +``` + +The address must be `https://` unless `insecure_http: true`. An optional `namespace` is sent as `X-Vault-Namespace`. AppRole `secret_id` comes from an environment variable or a file. Token renewal is lazy, on the next read; nothing spawns a background ticker. A `403` on a read triggers one reauthentication and one retry. + +An in-cluster Vault (Kubernetes service DNS, RFC 1918) is refused by the bundled transport unless the host opts in to private destinations. That opt-in applies to every outbound builtin, not only Vault; read [the warning]({{< relref "/docs/builtins#the-http-transport" >}}) before enabling it. -CPEX does not interpolate environment variables into arbitrary config values; there is no `${ENV}` substitution of config fields. Secrets are injected through typed source enums on the plugins that need them. +### Resolution and refresh -OAuth and CIBA client secrets use `client_secret_source`: +Shape is checked at load: a value naming an undeclared provider fails without contacting a backend. Every declared value is read during `initialize()`, before any plugin initializes, and a value that cannot be read stops startup. + +After startup, the host decides when to re-read. Nothing in the engine re-reads on its own. Call refresh on an interval and alarm on the last-success time: + +| Surface | Refresh | Last success | +|---|---|---| +| Rust | `engine.refresh_secrets().await` | `engine.secrets_last_success(provider)` | +| Python | `await engine.refresh_secrets()` | `engine.secrets_last_success(provider)` | +| Go | `engine.RefreshSecrets()` | `engine.SecretsLastSuccess(provider)` | +| C ABI | `cpex_refresh_secrets` | `cpex_secrets_last_success` | + +A value that fails to re-read keeps its last good bytes, and the report names every failure without the secret itself. Failed refreshes retain the last successful timestamp; inspect the refresh report's `failed` entries for current failures. Rust hosts can also query `engine.secrets_health(provider)`. Changes to resolved `secrets:` declarations require a restart; unused `env` provider settings are rejected. The interval you pick is the upper bound on how long a revoked credential stays in use. + +### Per-plugin secret sources + +OAuth and CIBA client secrets also take a typed `client_secret_source`: ```yaml client_secret_source: { kind: env_var, name: OAUTH_CLIENT_SECRET } # production-friendly @@ -161,28 +283,33 @@ client_secret_source: { kind: file, path: /run/secrets/oauth } # mounted s client_secret_source: { kind: literal, secret: dev-only } # never in production ``` -JWT signing material uses `decoding_key` on each `identity/jwt` trusted issuer: `jwks_url` (fetched and cached; `refresh_secs` default 600), `pem`, `pem_file`, `jwk`, or `secret` (HMAC). For a `jwks_url`, `insecure_http` defaults to `false`; set it `true` only to allow `http://` on localhost, never in production. +JWT signing material uses `decoding_key` on each `identity/jwt` trusted issuer: `jwks_url`, `pem`, `pem_file`, `jwk`, or `secret` (HMAC). A `jwks_url` is fetched at startup and refetched on demand when a token names an unknown `kid`, at most once per `min_refresh_interval_secs`. For a `jwks_url`, `insecure_http` defaults to `false`; set it `true` only to allow `http://` on localhost, never in production. (The request-time templates like `${args.X}` used inside PDP and predicate steps are a separate mechanism, evaluated per request against the attribute bag, not config interpolation.) ## Resolution order -With `routing_enabled: true`, the plugins that run for an operation are assembled and de-duplicated in this order, with later layers winning on conflict: +Under `dispatch: policy`, the steps that run for an operation are assembled in this order, with later layers winning on conflict: 1. always-on global policy, 2. the entity `defaults`, 3. groups the operation joins (via `groups:` or a matching tag), 4. the route itself. -Identity (`authentication:`) plugins stack global → groups → route, with `replace_inherited` to drop inherited layers when a route needs a clean set. +Identity (`authentication:`) stacks the same way: global, entity default, groups, route. `replace_inherited: true` drops the inherited layers when a route needs a clean set. ## Validation `load_config_yaml` validates on load and fails with an operator-facing message rather than starting in a bad state. Common errors: -- a duplicate plugin `name`; +- an unknown or renamed key, at any scope; +- a duplicate plugin `name`, or an unknown plugin `kind`; +- an unknown or removed hook name in `hooks:`; +- a declared plugin no policy reaches (under `dispatch: policy`); - a route with no entity matcher, or with more than one (for example both `tool:` and `resource:`); -- a route or group that references an unknown plugin name; -- the renamed key `identity:` (use `authentication:`). +- an `authorization:` block with no step; +- a JWT trusted issuer with no `audiences`. + +A missing `perform_http`, a missing HTTP transport, and an unreadable secret fail at `initialize()` instead, because they depend on what the host installed. -There is no hot reload or config versioning: load a changed config by rebuilding the manager. +There is no hot reload or config versioning: load a changed config by rebuilding the engine. diff --git a/docs/content/docs/crates.md b/docs/content/docs/crates.md new file mode 100644 index 00000000..5d7e4351 --- /dev/null +++ b/docs/content/docs/crates.md @@ -0,0 +1,64 @@ +--- +title: "Crate Map" +weight: 122 +--- + +# Crate Map + +CPEX runs on the praxis-policy engine (PPE). The engine, the APL compiler and runtime, and the bundled builtins are PPE crates from crates.io. This repository holds what CPEX adds on top: the `cpex` facade, the language bindings, the Python plugin host, and the Biscuit delegator. + +Most Rust hosts depend on `cpex` and nothing else. + +## CPEX crates and packages + +| Crate or package | Published | Role | +|---|---|---| +| [`cpex`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex) | crates.io | Host facade. Re-exports the whole public surface of `praxis-policy`, built with every stable builtin and the bundled hyper HTTP transport. Start here. | +| [`cpex-hosts-python`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-hosts-python) | crates.io | Runs existing Python CPEX plugins out of process, one cached virtualenv and one worker per plugin, under the `isolated_venv` kind. Reached through the `cpex` feature `python-host`. | +| [`cpex-plugin-delegator-biscuit`](https://github.com/contextforge-org/cpex/tree/main/builtins/plugins/delegator-biscuit) | crates.io | A `token.delegate` handler that attenuates Biscuit capability tokens. A Rust host registers it in code. | +| [`cpex-ffi`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-ffi) | signed release artifacts | C ABI over the engine (`cdylib` and `staticlib`). The Go package links it. | +| [`cpex-python`](https://github.com/contextforge-org/cpex/tree/main/bindings/python) | crates.io | PyO3 bindings, built with maturin into the Python package `cpex`: the Python `PolicyEngine`. | +| [`go/cpex`](https://github.com/contextforge-org/cpex/tree/main/go/cpex) | Go module | cgo wrapper over `cpex-ffi`: the Go `PolicyEngine`. | + +`cpex-ffi` and `cpex-python` depend on PPE directly rather than through the facade, and install the same builtins, transport and secret providers for their hosts. + +## PPE crates the facade re-exports + +The workspace pins `praxis-policy` and `praxis-policy-core` exactly to `=0.5.0`; `Cargo.lock` resolves every other PPE crate to 0.5.0 too. PPE has shipped breaking changes inside a minor series, so CPEX moves the pin deliberately and reruns its parity suite when it does. + +| PPE crate | Reached as | Role | +|---|---|---| +| `praxis-policy` | `cpex::*` | PPE's own facade. `cpex` re-exports it whole, so every item on `praxis_policy` is available under the same name: `PolicyEngine`, `install_builtins`, `prelude`, `AplOptions`, `register_apl`, `HyperTransport`, `install_default_http_transport`, the builtin factory types and their `KIND` constants. | +| `praxis-policy-core` | `cpex::praxis_policy_core` | The runtime: engine, phased executor, hook registry, config, extensions, identity, delegation, elicitation, secrets, audit and the HTTP transport seam. | +| `praxis-policy-apl-core` | `cpex::praxis_policy_apl_core` | APL compiler and evaluator: rules, effects, field pipelines, routes. | +| `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. | + +The builtins `cpex::install_builtins` registers: + +| Kind | Type | +|---|---| +| `identity/jwt` | identity resolver | +| `identity/api-key` | identity resolver | +| `delegator/oauth` | token delegator | +| `elicitation/ciba` | elicitation channel | +| `cedar-direct`, `cel`, `opa` | PDP resolvers | +| `valkey` | session store | + +The Vault KV v2 secret provider (`vault`) is compiled in too, but it is not a plugin: a Rust host adds it to its `SecretProviderRegistry` with `cpex::registry_with_vault(transport)` and installs that with `PolicyEngine::set_secret_providers`. The Python and Go bindings install `env`, `file` and `vault` for you, with Vault on the same transport as the builtins. PPE's experimental `quota` plugin is not part of the build. + +## 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 `praxis-policy-core` 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 + +There is no separate SDK crate. The plugin surface is `cpex::prelude`: the `Plugin` and `HookHandler` traits, payloads, results, `PluginFactory`, `TypedHandlerAdapter` and the CMF types. Implement `PluginFactory` against it and register it with `PolicyEngine::register_factory` under the `kind` your policy names. An unknown `kind` fails at load, so a missing registration shows up at startup. [Tutorial module 9]({{< relref "/docs/tutorial/09-custom-plugin" >}}) builds one end to end. + +## 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. diff --git a/docs/content/docs/deployment.md b/docs/content/docs/deployment.md index ff01f4f7..16920676 100644 --- a/docs/content/docs/deployment.md +++ b/docs/content/docs/deployment.md @@ -29,7 +29,7 @@ As a **gateway**, CPEX sits in front of the tool server and enforces on inbound ## Route forms -Routes are a list of `- tool:` entries (or `resource:` / `prompt:` / `llm:`), with the `authorization`, `args`, and `result` blocks under each. This is the form the runtime loads at every placement; the enforcement point changes, the config shape does not. See [Configuration]({{< relref "/docs/configuration" >}}) for the full structure. +Routes are a list of `- tool:` entries (or `resource:` / `prompt:` / `llm:` / `http:`), with the `authorization`, `args`, and `result` blocks under each. This is the form the runtime loads at every placement; the enforcement point changes, the config shape does not. See [Configuration]({{< relref "/docs/configuration" >}}) for the full structure. ## Placement guidance @@ -41,6 +41,17 @@ Routes are a list of `- tool:` entries (or `resource:` / `prompt:` / `llm:`), wi The decision is about which boundary you control and trust, not about policy capability. Identity resolution, PDP calls, delegation, redaction, and tainting all work the same at each. For what each placement does and does not defend against, see the [Threat Model]({{< relref "/docs/threat-model#where-the-boundary-sits-and-what-each-placement-covers" >}}). +## Outbound calls from the enforcement point + +Wherever CPEX runs, the builtins that fetch JWKS, exchange tokens, poll CIBA or read Vault make outbound HTTP calls through one transport the host installs. The bundled transport refuses loopback, private, link-local and CGNAT addresses. A deployment whose IdP or Vault sits on an in-cluster address has two choices: + +- install a host transport with its own egress rules (`set_http_transport` in Rust), which is the production answer; or +- turn on the private-destination opt-in for the surface (see [Builtins]({{< relref "/docs/builtins#private-destinations" >}})), which opens every private address, including cloud metadata, to every outbound builtin. + +The tutorial and demos use the second, which is fine for a laptop and wrong for a shared cluster. + +A host that uses Vault, or any rotating secret, also owns the refresh schedule: call secret refresh on an interval and alarm on the last-success time (see [Configuration]({{< relref "/docs/configuration#resolution-and-refresh" >}})). + ## Inference traffic When CPEX guards an agent's egress, route inference calls directly to the model provider rather than through the policy path, unless you intend to apply policy to them. Otherwise model traffic is evaluated as if it were a tool call. Reserve the enforced path for the operations you actually want mediated. diff --git a/docs/content/docs/extensions.md b/docs/content/docs/extensions.md index cb421873..318a5779 100644 --- a/docs/content/docs/extensions.md +++ b/docs/content/docs/extensions.md @@ -15,27 +15,28 @@ Each extension flattens into bag attributes under its namespace, gated by a read | Extension | Carries | Bag namespace | Read capability | |-----------|---------|---------------|-----------------| -| Security (subject) | subject id and type, roles, permissions, teams, claims, authentication status | `subject.id`, `subject.type`, `authenticated`, `role.*`, `perm.*`, `subject.teams`, `team.*`, `claim.*` | `read_subject`, `read_roles`, `read_permissions`, `read_teams`, `read_claims` | +| Security (subject) | subject id and type, roles, permissions, teams, claims, authentication status | `subject.id`, `subject.type`, `authenticated`, `subject.roles`, `subject.permissions`, `subject.teams`, `role.*`, `perm.*`, `team.*`, `claim.*` | `read_subject`, `read_roles`, `read_permissions`, `read_teams`, `read_claims` | | Security (client) | OAuth application identity: client id, trust level, roles, permissions, scopes, audiences, teams, claims | `client.*` | `read_client` | -| Security (workload) | attested workload identity (SPIFFE / mTLS) for this host and the inbound caller | `workload.*`, `caller_workload.*` | `read_workload` | -| Security (labels) | taint / classification labels for information-flow control | read directly from the extension (not materialized into bag keys) | `read_labels`, `append_labels` | +| Security (workload) | attested workload identity (SPIFFE / mTLS) for the inbound caller and for this instance | `caller_workload.*`, `this_workload.*` | `read_workload` | +| Security (labels) | taint / classification labels for information-flow control | `security.labels`, `security.classification` | `read_labels`, `append_labels` | | Delegation | delegation depth, delegated flag, origin and actor subjects, chain age | `delegation.*`, `delegated` | `read_delegation`, `append_delegation` | | 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` | | 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` | | 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` | | Framework | agentic framework name and version, node and graph ids, metadata | `framework.*` | `read_framework` | | Custom | free-form host-defined namespace | `custom.*` | `read_custom` | | Raw credentials | inbound tokens and minted delegated tokens | flow through plugin payloads, not the bag | `read_inbound_credentials`, `read_delegated_tokens` | -| Candidate constraint | folded backend routing constraint from `restrict` effects | not a bag namespace — read by the host router | written by the policy engine | +| Candidate constraint | folded backend routing constraint from `restrict` effects | not a bag namespace; read by the host router | `write_candidate_constraint` | -The request arguments and response body are also flattened, under `args.*` and `result.*`, and the route name is available as `route.key`. APL field pipelines (`args:` / `result:`) operate on those. Operator-maintained static attributes are flattened under `data.*` — these come from config files, not the request, and need no capability (see [Static Attributes]({{< relref "/docs/apl/attributes" >}})). +The request arguments and response body are also flattened. An object writes `args.` / `result.`; a top-level scalar or scalar array writes the bare key `args` / `result`. The route name is available as `route.key`. APL field pipelines (`args:` / `result:`) operate on those. Operator-maintained static attributes are flattened under `data.*`. These come from config files, not the request, and need no capability (see [Static Attributes]({{< relref "/docs/apl/attributes" >}})). -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. Because CPEX links the router in-process, this is a typed value, not a serialized blob. Capability-gating the write is not yet applied — the policy engine is its only writer today. +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. ## Capabilities @@ -44,7 +45,7 @@ A plugin declares the capabilities it needs. CPEX filters the extensions before ```yaml plugins: - name: audit-log - kind: audit/logger + kind: acme/audit hooks: [cmf.tool_pre_invoke] capabilities: - read_subject @@ -57,44 +58,54 @@ plugins: | Capability | Unlocks | |-----------|---------| | `read_subject` | `subject.id`, `subject.type`, `authenticated` | -| `read_roles` | `role.*` (plus the `read_subject` baseline) | -| `read_permissions` | `perm.*` (plus baseline) | -| `read_teams` | `subject.teams` (plus baseline; `team.*` mirrors teams) | +| `read_roles` | `subject.roles` and `role.*` aliases for undotted members (plus the `read_subject` baseline) | +| `read_permissions` | `subject.permissions` and `perm.*` aliases for undotted members (plus baseline) | +| `read_teams` | `subject.teams` and `team.*` aliases for undotted members (plus baseline) | | `read_claims` | `claim.*` (plus baseline) | | `read_client` | `client.*` | -| `read_workload` | `workload.*`, `caller_workload.*` | +| `read_workload` | `caller_workload.*`, `this_workload.*` | | `read_delegation` | `delegation.*`, `delegated` | | `read_agent` | `agent.*` | | `read_meta` | `meta.*` | | `read_request` | `request.*` | | `read_headers` | `http.method`, `http.path`, `http.host`, `http.scheme`, `http.request_headers.*`, `http.response_headers.*` | | `read_llm` | `llm.*` | +| `read_llm_request` | no bag keys; gates the parsed request body on the LLM request extension | | `read_mcp` | `mcp.*` | | `read_completion` | `completion.*` | | `read_provenance` | `provenance.*` | | `read_framework` | `framework.*` | | `read_custom` | `custom.*` | -| `read_labels` | no bag keys; the plugin reads labels from the security extension directly | +| `read_labels` | the labels on the security extension; a plugin reads them from the extension, while an APL predicate reads `security.labels` from the bag | | `read_inbound_credentials` | no bag keys; gates raw inbound tokens in the plugin payload | | `read_delegated_tokens` | no bag keys; gates minted tokens in the plugin payload | -`read_roles`, `read_permissions`, `read_teams`, and `read_claims` each imply the `read_subject` baseline (`subject.id`, `subject.type`, `authenticated`). The last three capabilities gate state that is not materialized into bag keys: labels are read from the extension, and credential material flows through plugin payloads rather than the bag, so granting them does not widen what an APL predicate can read. +`read_roles`, `read_permissions`, `read_teams`, and `read_claims` each imply the `read_subject` baseline (`subject.id`, `subject.type`, `authenticated`). The last three capabilities widen no plugin's bag view: labels are read from the typed extension, and credential material flows through plugin payloads rather than the bag. + +Membership names containing `.` stay atomic in the canonical sets and get no flattened alias. Test a dotted role with `subject.roles contains "admin.readonly"`; there is no `role.admin.readonly` key. The same holds for permissions, teams, and client roles and permissions. ### Write capabilities -Three capabilities grant write tokens rather than read access: +Four capabilities grant write tokens rather than read access: | Capability | Grants | |-----------|--------| -| `append_labels` | add a taint label (monotonic; cannot remove) | +| `append_labels` | add a taint or classification label (monotonic; cannot remove) | | `append_delegation` | extend the delegation chain (monotonic) | | `write_headers` | rewrite request and response headers (implies `read_headers`) | +| `write_candidate_constraint` | narrow the backends the router may select | + +### Gating an action: `perform_http` + +Every capability above gates a slot of contextual state. `perform_http` gates an action: reaching outside the process at all. It is the one capability where withholding it stops the call rather than degrading it. A plugin denied `read_claims` sees fewer attributes and carries on; a plugin denied its IdP call and carrying on regardless would decide without the answer it needed, which fails open. The engine therefore refuses to start and names the plugin and the capability to add. + +Every plugin that fetches JWKS, exchanges a token, dispatches a CIBA prompt, or queries an HTTP key directory must declare it. The host must also have installed an HTTP transport (see [Builtins]({{< relref "/docs/builtins#the-http-transport" >}})). ## Mutability tiers Extensions differ in how they may change during a request, and the runtime enforces the tier: -- **Immutable**: fixed once resolved. The verified subject identity, client, workload, agent, meta, request, LLM, MCP, completion, provenance, and framework extensions. +- **Immutable**: fixed once resolved. The verified subject identity, client, workload, agent, meta, request, LLM, LLM request, MCP, completion, provenance, and framework extensions. The LLM request is never serialized, so it stays out of extension dumps and session stores. - **Monotonic**: may only grow. Security labels (added via `append_labels`, never removed) and the delegation chain (extended via `append_delegation`). - **Mutable**: may be rewritten. HTTP headers (via `write_headers`) and the custom namespace. @@ -102,4 +113,4 @@ So a plugin cannot clear a taint label or rewrite a verified identity even if it ## How it connects to policy -Capability-gating runs at the boundary between the manager and each plugin (`filter_extensions` in cpex-core decides which extension slots a plugin sees; the CMF extractors then flatten those slots into the bag). The same filtered, tier-enforced view feeds the attribute bag APL evaluates, so a policy and the plugins it invokes operate on a consistent, least-privilege picture of the request. See [Identity]({{< relref "/docs/apl/identity" >}}) for how the subject is populated and [Session Tainting]({{< relref "/docs/apl/tainting" >}}) for the monotonic label tier in action. +Capability-gating runs at the boundary between the engine and each plugin: the engine decides which extension slots a plugin sees, and the CMF extractors then flatten those slots into the bag. The same filtered, tier-enforced view feeds the attribute bag APL evaluates, so a policy and the plugins it invokes operate on a consistent, least-privilege picture of the request. See [Identity]({{< relref "/docs/apl/identity" >}}) for how the subject is populated and [Session Tainting]({{< relref "/docs/apl/tainting" >}}) for the monotonic label tier in action. diff --git a/docs/content/docs/http-routing.md b/docs/content/docs/http-routing.md new file mode 100644 index 00000000..58916111 --- /dev/null +++ b/docs/content/docs/http-routing.md @@ -0,0 +1,143 @@ +--- +title: "HTTP Routing" +weight: 57 +--- + +# HTTP Routing + +Most routes select an operation by name: a `tool:`, a `resource:`, a `prompt:`, an `llm:`. An `http:` route selects on the request line instead, so plain HTTP traffic that names no entity can still be authorized. One CPEX engine can then cover an agent's tool calls and the HTTP traffic around them under one policy document. + +**Surfaces.** HTTP routes are reachable from the Rust facade only. The `http.request` and `http.response` hooks carry PPE's `HttpPayload`, which the Python and Go bindings do not build: a Python `invoke_hook("http.request", ...)` resolves the route and then fails with a `plugin_error` saying the `http` family expects `HttpPayload`. The Go `HttpExtension` also has no fields for the method, path or status. Python and Go hosts can still load a document that declares `http:` routes. + +## Selector forms + +A bare path, or a list of them, matches exactly: + +```yaml +routes: + - http: /admin + authorization: + pre_invocation: + - "require(role.admin)" + + - http: [/metrics, /healthz] + authorization: + pre_invocation: + - "allow" +``` + +The map form takes exactly one of `path:` or `path_prefix:`, optionally narrowed by `method:`: + +```yaml +routes: + - http: + path_prefix: /api/v1 + method: [POST, PUT, PATCH] + authorization: + pre_invocation: + - "require(perm.write)" + + - http: + path: /api/v1/status + method: GET + authorization: + pre_invocation: + - "allow" +``` + +Any other key in that map fails to load. + +## Precedence + +When more than one route could match, the winner is decided in this order: + +1. An exact path beats a prefix. +2. A longer prefix beats a shorter one. +3. A narrower method set breaks a remaining tie. + +`/api/v1/status` above wins over the `/api/v1` prefix next to it, whatever the declaration order. + +Exact paths compare byte for byte, so `/admin` and `/admin/` are different paths. A prefix drops one trailing slash at parse time, so `path_prefix: /api/` and `path_prefix: /api` are the same selector. A prefix matches on segment boundaries: `/api` does not match `/apidocs`. + +## The catch-all + +A request that matches no `http:` route falls through to global policy. A document that declares `http:` routes without a catch-all is reported at load, because falling through is a real decision. Write one with the root prefix: + +```yaml +routes: + - http: + path_prefix: / + authorization: + pre_invocation: + - "require(authenticated)" +``` + +## Invoking it from Rust + +The host fires `http.request` with `meta` set to the reserved coordinates (`entity_type: http`, `entity_name: global`) and the request line on the HTTP extension. The engine picks the route from the request line. + +```rust +use std::sync::Arc; + +use cpex::praxis_policy_core::cmf::constants::{ENTITY_HTTP, ENTITY_NAME_GLOBAL}; +use cpex::praxis_policy_core::extensions::{Extensions, HttpExtension, MetaExtension}; +use cpex::praxis_policy_core::http_hook::{HttpHook, HttpPayload, HOOK_HTTP_REQUEST}; +use cpex::PolicyEngine; + +/// An HTTP request as the engine expects it: the reserved entity +/// coordinates on `meta`, and the request line on `http`. +fn http_request(method: &str, path: &str) -> Extensions { + let mut meta = MetaExtension::default(); + meta.entity_type = Some(ENTITY_HTTP.to_owned()); + meta.entity_name = Some(ENTITY_NAME_GLOBAL.to_owned()); + Extensions { + meta: Some(Arc::new(meta)), + http: Some(Arc::new(HttpExtension { + method: Some(method.to_owned()), + path: Some(path.to_owned()), + ..Default::default() + })), + ..Default::default() + } +} + +// engine: Arc, loaded and initialized +let (result, _bg) = engine + .invoke_named::(HOOK_HTTP_REQUEST, HttpPayload, http_request("GET", "/admin"), None) + .await; +``` + +With the `/admin` route and the catch-all above, an anonymous `GET /admin` denies with `routes.http:/admin.pre_invocation[0]`, and `GET /docs` allows. + +## Host obligations + +If the host skips either of these, a different policy runs and the request does not fail. + +**The request line at the identity hook.** A route's own `authentication:` list applies only if the engine knows the method and path when identity is resolved. Put them on the HTTP extension for the `identity.resolve` call too. Without them the global `authentication:` list runs instead, and the engine logs a warning. + +**The response hook, fired by the host.** A global `result:` or `post_invocation:` block installs a handler on `http.response`, but the host has to invoke it. Before you do, review your global post steps: HTTP steps that did nothing before now run, and `result.*` is absent for a request with no entity. + +Response bodies are not modeled. The response half covers headers and extensions. + +## Reading the response status + +`http.status` carries the response status to post-phase policy. The host sets it on the response invocation. It enters the attribute bag as an integer under the `read_headers` capability, and it is absent on requests, so a rule that reads it belongs under `post_invocation:`: + +```yaml +routes: + - http: + path_prefix: /api + authorization: + post_invocation: + - "require(http.status < 500)" +``` + +## Dispatch mode + +`http:` routes need the default `dispatch: policy`. Under `dispatch: hooks` a `routes:` block fails to load, so a document with an `http:` route is in policy mode by construction. A route's policy body replaces its structural plugin chain, so a listed plugin runs only where a step names it with `run(name)`. + +## 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. +- [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 new file mode 100644 index 00000000..fa51cead --- /dev/null +++ b/docs/content/docs/identity-claim-mapping.md @@ -0,0 +1,115 @@ +--- +title: "Identity Claim Mapping" +weight: 56 +--- + +# Identity Claim Mapping + +The `identity/jwt` resolver builds a subject, a client and a workload identity from a token's claims. Which claims hold roles, scopes or the client id differs by IdP, so the mapping is configuration. You name a shipped preset or write the map inline, under the plugin's `config:`. + +**Surfaces.** Mapping runs inside the engine, so the same config works from Rust, Python and Go. Python hosts call `identity.resolve` with an identity payload dict (`{"source": "bearer", "headers": {...}}`); Go hosts use `NewIdentityPayload`. The resolved subject comes back on the result's modified payload. + +## Presets + +```yaml +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + claim_mapper: keycloak # standard | keycloak | auth0 | cognito | ibmverify + trusted_issuers: [ ... ] +``` + +Naming no mapper resolves to `standard`, so an existing config is unaffected. + +| Preset | Reads | Leaves out on purpose | +|---|---|---| +| `standard` | `sub`; `roles`; `permissions` or `scope`; `teams` or `groups`; for a client `client_id` or `azp`, `client_name`, scopes, `aud`, roles; a SPIFFE ID from `sub` or `spiffe_id` for a workload | Nothing; collections are read as arrays only. | +| `keycloak` | `realm_access.roles` into roles, `scope` into permissions, `client_id` / `azp` / `clientId` as the client | Per-client roles (`resource_access..roles`), because the client id is yours to choose; teams, because Keycloak's default `groups` claim holds realm roles. | +| `auth0` | `sub`, the `permissions` array ahead of `scope`, `client_id` / `azp` | Roles and teams, which Auth0 only allows under a namespace that is specific to your deployment. | +| `cognito` | `sub`, `cognito:groups` into teams, `scope` into permissions, `client_id` | Roles (`cognito:roles` holds IAM role ARNs) and the client audience. | +| `ibmverify` | Tenant-provisioned roles, teams, permissions, client roles and scopes, as a scalar or an array | A workload section; `gh_permissions` and `manager`, which policy reads as raw claims. | + +Each preset leaves a field empty rather than fill it with the wrong concept. Use `ibmverify` for IBM Verify: Verify emits a one-value collection as a bare string, and `standard` would read that as empty. That shows up as a policy deny, not as a config error. + +## An inline map + +`claim_map` uses the same schema as a preset. Candidate paths are tried in order: + +```yaml +config: + claim_map: + subject: + roles: + paths: + - realm_access.roles + - resource_access.my-api.roles + merge: union # first_match (default) | union + permissions: + paths: + - { path: permissions, array_only: true } + - scope + split: whitespace # break a delimited string into elements + on_missing: deny # ignore (default) | deny +``` + +Sections and the fields each one may map: + +| Section | Fields | +|---|---| +| `subject` | `id`, `roles`, `permissions`, `teams` | +| `client` | `client_id`, `client_name`, `roles`, `permissions`, `teams`, `authorized_scopes`, `authorized_audiences` | +| `workload` | `spiffe_id`, `client_id`, `selectors` | + +A workload's trust domain is always derived from its SPIFFE ID, never read from a claim. + +A field takes one of three forms: + +- `id: sub`: one path. +- `teams: [teams, groups]`: ordered candidates. +- `roles: { paths: [...], merge: union }`: candidates plus options. + +Options: + +| Option | Effect | +|---|---| +| `array_only` | Accept only an array at this path. | +| `string_only` | Accept only a string at this path. | +| `stop_if_present` | If the claim exists but its value is unusable, stop instead of trying the next candidate. Scalar fields only. | +| `split: whitespace` | Split a string such as an OAuth `scope`. | +| `merge: union` | Combine every resolving candidate. Collections only. | +| `on_missing: deny` | Refuse the token when no candidate resolves, instead of leaving the field empty. | + +`.` separates path segments and `\` escapes one. Every other character, `:` and `/` included, is literal: `cognito:groups` is one segment written as is, and escaping the colon is rejected. A field whose candidates all miss is left empty and logged with every path tried. + +A malformed map fails when the plugin is built, naming the field and path, so a bad map never reaches a request. + +## Gating on the issuer + +Registered claims such as `iss` are dropped from the policy-visible claims by default. `claims:` overrides that. It sits next to `claim_mapper` or `claim_map`: + +```yaml +config: + claim_mapper: keycloak + claims: + include: [iss] # keep one the mapper drops + exclude: [internal_debug] # drop an otherwise visible claim +``` + +`claim.iss` is then readable from policy, so a deployment trusting several issuers can gate on which one signed. Both lists take top-level claim names. A dotted entry fails to load; a claim whose own name has a dot is written with `\.`. + +## 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. + +## 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. + +## Go deeper + +- [Identity & Delegation]({{< relref "/docs/identity-delegation" >}}): resolving and delegating identity end to end. +- [APL identity]({{< relref "/docs/apl/identity" >}}): the `authentication:` block. +- [CMF Extensions]({{< relref "/docs/cmf-extensions" >}}): how the resolved subject and claims become `subject.*`, `role.*` and `claim.*` keys. diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md index 162d0349..2155a1f4 100644 --- a/docs/content/docs/identity-delegation.md +++ b/docs/content/docs/identity-delegation.md @@ -5,19 +5,18 @@ weight: 55 # Identity & Delegation -CPEX does two identity jobs on every request: it resolves **who is calling in** -(inbound identity) and mints **the credential it calls out with** (outbound -delegation). Find the shape you need ("a user acting through an agent", "an agent -acting as itself", "a service acting as itself"), copy the plugin config and the -route layout, and check the support matrix for where it has been tested. +CPEX resolves the inbound caller and mints the outbound credential on every +request. Select the recipe matching the required principal, copy its plugin and +route configuration, then check the support matrix for tested IdPs. -The same config runs wherever you place it: in front of the tools, inside the tool -server, or agent-side (see [Where to place CPEX](#where-to-place-cpex)). Throughout -this page "the enforcement point" means "wherever this CPEX instance runs." +The same config runs wherever you place it: in front of the tools, inside the +tool server, or agent-side (see [Where to place CPEX](#where-to-place-cpex)). +Throughout this page "the enforcement point" means "wherever this CPEX instance +runs." -For the conceptual model first, read [Use Cases]({{< relref "use-cases" >}}) and -[Deployment]({{< relref "deployment" >}}); for the full config schema, see -[Configuration]({{< relref "configuration" >}}). +For the conceptual model first, read [Use Cases]({{< relref "/docs/use-cases" >}}) and +[Deployment]({{< relref "/docs/deployment" >}}); for the full config schema, see +[Configuration]({{< relref "/docs/configuration" >}}). ## The model in one picture @@ -25,12 +24,12 @@ Every request crosses two identity boundaries: ![Two identity boundaries. Inbound: identity.resolve validates credentials and fills typed identity slots, additively. Outbound: token.delegate mints the downstream credential, chosen per route by subject.](images/identity_two_boundaries.png) -- **Inbound.** `identity.resolve` plugins each read one credential (from a header) - and land a typed identity in a slot. They are additive: one request can carry a - user token *and* a workload SVID, both validated. -- **Outbound.** A `delegate(...)` step in the route runs a `token.delegate` plugin - that mints the credential attached to the upstream call. The step's `subject:` - chooses what the minted token *speaks for*. +- Inbound. `identity.resolve` plugins each read one credential (from a + header) and land a typed identity in a slot. They are additive: one request + can carry a user token *and* a workload SVID, both validated. +- Outbound. A `delegate(...)` step in the route runs a `token.delegate` + plugin that mints the credential attached to the upstream call. The step's + `subject:` chooses what the minted token *speaks for*. ## Building blocks @@ -44,17 +43,18 @@ Every request crosses two identity boundaries: ### Delegation subjects (outbound) -The `subject:` on a `delegate(...)` step decides the OAuth mechanism the delegator uses: +The `subject:` on a `delegate(...)` step decides the OAuth mechanism the +delegator uses: | `subject:` | Minted token speaks for | Mechanism | |---|---|---| | `user` (default) | the human, on-behalf-of | RFC 8693 token exchange (`subject_token` = the user's token) | | `caller_workload` | the calling agent, as itself | RFC 7523 client assertion (the SVID) → then scope down | -| `this_workload` | **the CPEX instance's own identity**, as itself, no inbound credential | RFC 6749 §4.4 `client_credentials` | +| `this_workload` | the CPEX instance's own identity, as itself, no inbound credential | RFC 6749 §4.4 `client_credentials` | -> `this_workload` names *this CPEX instance acting as its own identity*, whatever you've -> deployed it as. It does not claim CPEX is a gateway. (`gateway` is accepted as a -> deprecated alias.) +> `this_workload` names *this CPEX instance acting as its own identity*, whatever +> you've deployed it as. It does not claim CPEX is a gateway. (`gateway` is +> accepted as a deprecated alias.) ### "I want to…" → mechanism @@ -64,37 +64,52 @@ The `subject:` on a `delegate(...)` step decides the OAuth mechanism the delegat | act on behalf of a signed-in user | `subject: user` → token exchange | RFC 8693 | | let a workload authenticate as *itself* by its SVID | `subject: caller_workload` → client assertion | RFC 7523 + `draft-ietf-oauth-spiffe-client-auth` | | record both the user *and* the calling agent in the token | `subject: user`, `actor: caller_workload` | RFC 8693 `actor_token` | -| forward a token the caller already obtained | no `delegate`, pass the header through | — | +| forward a token the caller already obtained | no `delegate`, pass the header through | none | +| authenticate a caller holding an opaque API key | `identity/api-key` → directory lookup | none | --- ## Scoping: how broadly to apply it -CPEX resolves the pipeline for each request across one **broad → narrow stack**, and -**both identity and policy (including delegation) ride it**. Narrower layers add to -(or override) broader ones. Pick the broadest layer that's still correct. +CPEX resolves the pipeline for each request across one broad → narrow stack, +and both identity and policy (including delegation) ride it. Narrower layers +add to (or override) broader ones. Pick the broadest layer that's still +correct. -A **group** is a named, reusable bundle of policy (authentication steps + -authorization steps + plugins) that routes opt into. The layers, broad to narrow: +A group is a named, reusable bundle of policy (authentication steps + +authorization steps + plugins) that routes opt into. The layers, broad to +narrow: | Layer | Applies to | Identity uses… | Policy / delegation uses… | |---|---|---|---| -| **Global** | every request | `global.authentication` | always-on global policy | -| **Default** (per entity type) | every tool / prompt / resource | — | `global.defaults.` | -| **Group** | routes that join `` (via `groups:` or a matching tag) | `groups..authentication` | `groups..authorization` / `plugins` | -| **Route (entity)** | one route (a `tool: "*"` route is the catch-all) | route `authentication:` | route `authorization:` steps / `plugins:` | +| Global | every request | `global.authentication` | always-on global policy | +| Default (per entity type) | every tool / prompt / resource | none | `global.defaults.` | +| Group | routes that join `` (via `groups:` or a matching tag) | `groups..authentication` | `groups..authorization` / `plugins` | +| Route (entity) | one route (a `tool: "*"` route is the catch-all) | route `authentication:` | route `authorization:` steps / `plugins:` | -So a `delegate(...)` is *not* route-only. To pick its breadth, put it (or a +`delegate(...)` is not route-only. Place it (or a `token.delegate` plugin) at the matching layer: -- **every tool** → a `delegate()` in a `tool: "*"` route, or the delegator plugin in - `defaults.tool`, -- **a class of tools** → a group, -- **one tool** → a specific route (which overrides the `*` default; more specific wins). +- every tool → a `delegate()` in a `tool: "*"` route, or the delegator + plugin in `defaults.tool`, +- a class of tools → a group, +- one tool → a specific route (which overrides the `*` default; more + specific wins). ### The stack in one config ```yaml +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + - name: jwt-manager + kind: identity/jwt + hooks: [identity.resolve] + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + global: authentication: [jwt-user] # every request gets user identity @@ -113,47 +128,63 @@ routes: - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" ``` -`groups: hr-tools` is the first-class way to join a group, and it is **sugar over -tags**: `meta: { tags: [hr-tools] }` is exactly equivalent, and host-injected runtime -tags join groups the same way. +`groups: hr-tools` is the first-class way to join a group, and it is sugar +over tags: `meta: { tags: [hr-tools] }` is exactly equivalent, and +host-injected runtime tags join groups the same way. -**The override.** A route that must stand alone drops the inherited layers: +The override. A route that must stand alone drops the inherited layers: ```yaml +plugins: + - name: jwt-workload + kind: identity/jwt + hooks: [identity.resolve] + routes: - tool: get_directory authentication: - replace_inherited: true # ignore global + group layers… - steps: [jwt-workload] # …authenticate by the SVID alone + replace_inherited: true # ignore global + group layers + steps: [jwt-workload] # authenticate by the SVID alone ``` That is what [Recipe 2](#recipe-2-agent-acting-as-itself-by-its-spiffe-svid) -uses. (Full group / defaults syntax: [Configuration]({{< relref "configuration" >}}).) +uses. (Full group / defaults syntax: [Configuration]({{< relref "/docs/configuration" >}}).) ### Rule of thumb | You want… | Put it at | |---|---| -| the same identity everywhere | **global** `authentication` | -| a recurring identity/plugin set across many tools | a **group** | -| one route handled differently, standalone | a **route** (`authentication: replace_inherited` for identity) | -| one delegation for most tools, exceptions for a few | a **default** (`tool: "*"` route or `defaults.tool`) + specific overrides | +| the same identity everywhere | global `authentication` | +| a recurring identity/plugin set across many tools | a group | +| one route handled differently, standalone | a route (`authentication: replace_inherited` for identity) | +| one delegation for most tools, exceptions for a few | a default (`tool: "*"` route or `defaults.tool`) + specific overrides | --- ## Recipes -Each recipe is a drop-in: the plugins it needs, the route layout, and where it has -been run. All config is [unified-config]({{< relref "configuration" >}}) YAML. - -> **Canonical keys.** These recipes write policy under `authorization:` (with -> `pre_invocation:` / `post_invocation:` inside), the orchestrator-agnostic spelling. -> The older `apl:` wrapper is still accepted, and `pre_invocation:` may also be written -> flat on the route; all three compile identically. +Each recipe is a drop-in: the plugins it needs, the route layout, and where +it's been tested. Identity recipes work from every surface. The delegation +recipes need a Rust host, or Go with `InvokeResolved`; the Python bindings do +not carry inbound tokens into a delegation step. All config is [unified-config]({{< relref "/docs/configuration" >}}) YAML. + +> One spelling. These recipes write policy under `authorization:`, +> with `pre_invocation:` and `post_invocation:` inside it. That is now +> the only spelling. The `apl:` wrapper is gone at every scope, and a +> phase list written flat on a route is a load error. Both were +> accepted once; see [Migrating to 0.3]({{< relref "/docs/migration-0.3" >}}) for the rewrite. + +> Outbound calls. Every `identity/jwt` with a `jwks_url`, every +> `delegator/oauth` and an `identity/api-key` with an HTTP directory +> declares `capabilities: [perform_http]`, and the host installs an HTTP +> transport. The short declarations under "Scoping" omit `config:` and +> capabilities for brevity. An IdP on `localhost` or a private address +> also needs the host's private-destination opt-in; see +> [Builtins]({{< relref "/docs/builtins#private-destinations" >}}). ### Recipe 1: User acting through an agent (on-behalf-of) -**When:** a human is signed in; the agent calls a downstream API *as that user*. +When: a human is signed in; the agent calls a downstream API *as that user*. CPEX exchanges the user's IdP token for a downstream-audience token. ```yaml @@ -161,6 +192,7 @@ plugins: - name: jwt-user kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] # fetches the IdP's JWKS config: role: user header: X-User-Token @@ -173,11 +205,11 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/token" - client_id: "cpex" # this CPEX instance's own OAuth client - client_secret_source: { kind: env_var, name: CPEX_CLIENT_SECRET } + client_id: "cpex" # this CPEX instance's own OAuth client + client_secret_source: { kind: env_var, name: IDP_CLIENT_SECRET } global: authentication: [jwt-user] @@ -190,21 +222,22 @@ routes: - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" ``` -The minted `workday-api` token is attached to the upstream call. **Tested: Keycloak -26.x (Standard Token Exchange v2).** +The minted `workday-api` token is attached to the upstream call. Tested: +Keycloak 26.x (Standard Token Exchange v2). ### Recipe 2: Agent acting as itself, by its SPIFFE SVID -**When:** the *agent* is the principal (no human), and you don't trust the agent to -hold downstream authority. The agent presents its SVID; CPEX brokers a scoped -downstream token. The agent holds no standing entitlement to the target. - -> **The SVID is a JWT, but not an IdP token.** A JWT-SVID is an `ES256` JWT signed by -> *SPIRE* (validated against SPIRE's JWKS, not your IdP's): a SPIFFE identity -> credential, not an OAuth access token. It can't be forwarded to the downstream or -> used as a bearer/subject token as-is; CPEX has to **turn it into an IdP-issued token -> first** (leg 1 below). Contrast -> [Recipe 5](#recipe-5-scope-a-token-the-agent-already-holds-1-leg), whose input is a +When: the *agent* is the principal (no human), and you don't trust the agent +to hold downstream authority. The agent presents its SVID; CPEX brokers a +scoped downstream token. The agent holds no standing entitlement to the +target. + +> The SVID is a JWT, but not an IdP token. A JWT-SVID is an `ES256` JWT +> signed by *SPIRE* (validated against SPIRE's JWKS, not your IdP's): a SPIFFE +> identity credential, not an OAuth access token. It can't be forwarded to the +> downstream or used as a bearer/subject token as-is; CPEX must turn it into +> an IdP-issued token first (leg 1 below). Contrast [Recipe +> 5](#recipe-5-scope-a-token-the-agent-already-holds-1-leg), whose input is a > token already *minted from* an SVID. Add a workload resolver, scoped to the route so only it runs there: @@ -214,6 +247,7 @@ plugins: - name: jwt-workload kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] on_error: fail config: role: caller_workload @@ -233,26 +267,29 @@ routes: steps: [jwt-workload] authorization: pre_invocation: - - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation], subject: caller_workload)" + - "delegate(workday-oauth, target: workday-api, + audience: workday-api, + permissions: [read_compensation], + subject: caller_workload)" - "!delegation.granted: deny" ``` -For `subject: caller_workload`, the OAuth delegator runs **two legs**: leg 1 presents -the SVID as an RFC 7523 `client_assertion` (type `…:jwt-spiffe`) to authenticate the -agent as its IdP client; leg 2 exchanges that for the scoped downstream token. The -IdP side needs a SPIFFE identity provider (validates the SVID against SPIRE's trust -bundle) and a client bound to that SVID via SPIFFE/federated client authentication; -consult your IdP's SPIFFE client-auth docs. **Tested: Keycloak 26.6 (feature -`spiffe:v1`).** +For `subject: caller_workload`, the OAuth delegator runs two legs: leg 1 +presents the SVID as an RFC 7523 `client_assertion` (type `…:jwt-spiffe`) to +authenticate the agent as its IdP client; leg 2 exchanges that for the scoped +downstream token. The IdP side needs a SPIFFE identity provider (validates the +SVID against SPIRE's trust bundle) and a client bound to that SVID via +SPIFFE/federated client authentication; consult your IdP's SPIFFE client-auth +docs. Tested: Keycloak 26.6 (feature `spiffe:v1`). -> Why route-scope it: keeping the workload authority off the agent's own identity, -> and requiring the enforcement point's credential for the scope-up, is what makes -> CPEX the trust boundary. A compromised agent can prove who it is but cannot mint -> the downstream token itself. +> Why route-scope it: keeping the workload authority off the agent's own +> identity, and requiring the enforcement point's credential for the scope-up, +> is what makes CPEX the trust boundary. A compromised agent can prove who it is +> but cannot mint the downstream token itself. ### Recipe 3: A service acting as itself -**When:** CPEX calls a downstream as *itself*, with no inbound credential to exchange +When: CPEX calls a downstream as *itself*, with no inbound credential to exchange (e.g. a scheduled job, or CPEX's own housekeeping). ```yaml @@ -264,32 +301,34 @@ routes: ``` `subject: this_workload` switches the delegator to `client_credentials`: no -`subject_token`, CPEX's own `client_id`/secret is the identity. **Tested: Keycloak -(client_credentials).** +`subject_token`, CPEX's own `client_id`/secret is the identity. Tested: +Keycloak (client_credentials). ### Recipe 4: Forward a token the caller already has (passthrough) -**When:** the agent authenticated to the IdP itself and hands CPEX a ready token. +When: the agent authenticated to the IdP itself and hands CPEX a ready token. CPEX validates it inbound and lets the route forward it, with no `delegate` step. This is the "agent-brokered" case; it needs no delegation code, only that the inbound resolver validates the token and the route allows the call. ### Recipe 5: Scope a token the agent already holds (1-leg) -**When:** the agent authenticated to the IdP *itself* with its SVID and got back a -normal JWT, and you still want CPEX to narrow that token per-tool (least privilege -at the boundary) without ever handling the SVID. +When: the agent authenticated to the IdP *itself* with its SVID and got back +a normal JWT, and you still want CPEX to narrow that token per-tool (least +privilege at the boundary) without ever handling the SVID. -> **The token is not the SVID.** The agent presented its SVID as a `client_assertion` -> *upstream* and received an ordinary IdP access token. That token arrives here **like -> a user token**: same header, same JWKS validation, `RS256` (an IdP-signed -> JWT), *not* the `ES256` SVID. CPEX never sees the SVID; it sees a normal token. +> The token is not the SVID. The agent presented its SVID as a +> `client_assertion` *upstream* and received an ordinary IdP access token. That +> token arrives here like a user token: same header, same JWKS validation, +> `RS256` (an IdP-signed JWT), *not* the `ES256` SVID. CPEX never sees the SVID; +> it sees a normal token. ```yaml plugins: - name: jwt-agent kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: client # the agent as an OAuth client header: Authorization # a normal bearer token, NOT X-Workload-Token @@ -302,44 +341,57 @@ routes: - tool: get_directory authorization: pre_invocation: - - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation], subject: client)" + - "delegate(workday-oauth, target: workday-api, + audience: workday-api, + permissions: [read_compensation], + subject: client)" ``` -This is a **plain RFC 8693 exchange**, the same engine as -[Recipe 1](#recipe-1-user-acting-through-an-agent-on-behalf-of), scoping the -*agent's* token instead of a user's. **One leg** (the scope): the agent did +This is a plain RFC 8693 exchange, the same engine as [Recipe +1](#recipe-1-user-acting-through-an-agent-on-behalf-of), scoping the +*agent's* token instead of a user's. One leg (the scope): the agent did the authenticate leg upstream, so CPEX doesn't. -**Don't confuse this with Recipe 2.** The trigger is *what the agent presents*: +Don't confuse this with Recipe 2. The trigger is *what the agent presents*: an SVID, or a token minted from one: | Agent presents | Slot → subject | CPEX does | Legs | |---|---|---|---| -| its **SVID** (`ES256`, SPIRE JWKS) | `caller_workload` → `subject: caller_workload` | authenticate + scope | 2 (Recipe 2) | -| a **token minted from its SVID** (`RS256`, IdP JWKS) | `client` → `subject: client` | scope only | 1 (this recipe) | -| a **token already right** for the tool | — | forward as-is | 0 (Recipe 4) | +| its SVID (`ES256`, SPIRE JWKS) | `caller_workload` → `subject: caller_workload` | authenticate + scope | 2 (Recipe 2) | +| a token minted from its SVID (`RS256`, IdP JWKS) | `client` → `subject: client` | scope only | 1 (this recipe) | +| a token already right for the tool | none | forward as-is | 0 (Recipe 4) | -Using `subject: caller_workload` on an already-minted token misroutes it -down the two-leg `client_assertion` path. +Using `subject: caller_workload` on an already-minted token misroutes it down +the two-leg `client_assertion` path. ### Recipe 6: User acting through an agent, with the agent named (dual-principal) -**When:** a human is signed in *and* you want the record to name the agent that +When: a human is signed in *and* you want the record to name the agent that carried out the call. The minted token speaks for the user (`sub`), and CPEX -additionally names the calling agent as the RFC 8693 acting party (`act`), so a token -service that honors delegation records both who authorized the action and who -performed it. +additionally names the calling agent as the RFC 8693 acting party (`act`), so a +token service that honors delegation records both who authorized the action and +who performed it. -It composes two inbound resolvers: `jwt-user` -([Recipe 1](#recipe-1-user-acting-through-an-agent-on-behalf-of)) for the human on -`X-User-Token`, and `jwt-workload` -([Recipe 2](#recipe-2-agent-acting-as-itself-by-its-spiffe-svid)) for the agent's -SVID on `X-Workload-Token`. Both must resolve; both credentials arrive on every call. +It composes two inbound resolvers: `jwt-user` ([Recipe +1](#recipe-1-user-acting-through-an-agent-on-behalf-of)) for the human on +`X-User-Token`, and `jwt-workload` ([Recipe +2](#recipe-2-agent-acting-as-itself-by-its-spiffe-svid)) for the agent's SVID on +`X-Workload-Token`. Both must resolve; both credentials arrive on every call. ```yaml +plugins: + - name: jwt-user # Recipe 1 + kind: identity/jwt + hooks: [identity.resolve] + - name: jwt-workload # Recipe 2 + kind: identity/jwt + hooks: [identity.resolve] + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + global: - # define jwt-user (Recipe 1) and jwt-workload (Recipe 2); run both - authentication: [jwt-user, jwt-workload] + authentication: [jwt-user, jwt-workload] # run both routes: - tool: get_compensation @@ -354,73 +406,303 @@ routes: `subject: user` makes the user's token the RFC 8693 `subject_token` (exactly as [Recipe 1](#recipe-1-user-acting-through-an-agent-on-behalf-of)); `actor: caller_workload` *additionally* attaches the agent's SVID as the `actor_token`, -requesting that the minted token carry `act` alongside `sub`. This is one exchange -call with two principals in the request, not a second leg. `actor` accepts only -inbound credentials (`user`, `client`, `caller_workload`): the acting party is by -definition one that presented itself to CPEX. +requesting that the minted token carry `act` alongside `sub`. This is one +exchange call with two principals in the request, not a second leg. `actor` +accepts only inbound credentials (`user`, `client`, `caller_workload`): the +acting party is by definition one that presented itself to CPEX. -> **Subject vs. actor.** The *subject* is who the token speaks *for* (whose -> authority); the *actor* is who is *doing* it (attribution). Least-privilege scoping -> still follows the subject. The `act` claim records the agent, it doesn't grant it -> anything. +> Subject vs. actor. The *subject* is who the token speaks *for* (whose +> authority); the *actor* is who is *doing* it (attribution). Least-privilege +> scoping still follows the subject. The `act` claim records the agent, it +> doesn't grant it anything. -**Which actor, `client` or `caller_workload`?** Match it to *how the agent -authenticated*. An agent that presented a SPIFFE SVID is a `caller_workload` (above); -one that authenticated as a registered OAuth client (an `Authorization` bearer token, -resolved with `role: client`) is `actor: client`: +Which actor, `client` or `caller_workload`? Match it to *how the agent +authenticated*. An agent that presented a SPIFFE SVID is a `caller_workload` +(above); one that authenticated as a registered OAuth client (an `Authorization` +bearer token, resolved with `role: client`) is `actor: client`: ```yaml - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation], subject: user, actor: client)" ``` -> **Valid combinations.** `actor:` pairs with `subject: user` or `subject: client`, -> the on-behalf-of shape. It is **not** supported with `subject: caller_workload` -> (the workload is already the subject) or `subject: this_workload` (a -> `client_credentials` grant carries no `actor_token`); CPEX rejects those at -> config time rather than silently dropping the actor. +> Valid combinations. `actor:` pairs with `subject: user` or `subject: +> client`, the on-behalf-of shape. It is not supported with `subject: +> caller_workload` (the workload is already the subject) or `subject: +> this_workload` (a `client_credentials` grant carries no `actor_token`); CPEX +> rejects those at config time rather than silently dropping the actor. -**CPEX side: implemented and e2e-tested against a mock IdP.** The delegator puts the +CPEX side: implemented and e2e-tested against a mock IdP. The delegator puts the actor on the wire exactly as RFC 8693 delegation prescribes (`actor_token` + `actor_token_type`), and omits it when no actor is configured. -> **Interop: `act` is the token service's job (impersonation vs. delegation).** -> RFC 8693 (§1.1) exchanges come in two flavors. *Impersonation* returns a token that -> speaks purely for the subject, indistinguishable from one the subject fetched -> directly, with **no `act` claim**. *Delegation* additionally records the actor in a -> nested `act`. The `actor_token` parameter is what asks for delegation; only a token -> service that implements the delegation path emits `act`. **CPEX always sends the -> delegation request, but the claim only appears if the service honors it.** +> Interop: `act` is the token service's job (impersonation vs. delegation). +> RFC 8693 (§1.1) exchanges come in two flavors. *Impersonation* returns a token +> that speaks purely for the subject, indistinguishable from one the subject +> fetched directly, with no `act` claim. *Delegation* additionally records +> the actor in a nested `act`. The `actor_token` parameter is what asks for +> delegation; only a token service that implements the delegation path emits +> `act`. CPEX always sends the delegation request, but the claim only appears +> if the service honors it. > -> **Keycloak does not.** Keycloak's Standard Token Exchange (v2, tested here on 26.6) -> implements impersonation only: it **silently ignores `actor_token`** and returns a -> subject-only token with no `act`. The tell (probed 2026-07-28): passing even a raw, -> untrusted-issuer SVID as the exchange's `actor_token` produces **no error**. Keycloak -> never parses the parameter, so no mapper or config can surface it. To see `act` -> end-to-end you need a delegation-capable token service; against Keycloak, capture the -> acting agent at the CPEX boundary (audit / downstream header) instead. CPEX resolves -> both principals either way. +> Keycloak does not. Keycloak's Standard Token Exchange (v2, tested here on +> 26.6) implements impersonation only: it silently ignores `actor_token` and +> returns a subject-only token with no `act`. The tell (probed 2026-07-28): +> passing even a raw, untrusted-issuer SVID as the exchange's `actor_token` +> produces no error. Keycloak never parses the parameter, so no mapper or +> config can surface it. To see `act` end-to-end you need a delegation-capable +> token service; against Keycloak, capture the acting agent at the CPEX boundary +> (audit / downstream header) instead. CPEX resolves both principals either way. --- +### Recipe 7: An opaque API key, resolved against a directory + +Use `identity/api-key` when a caller presents an opaque key. The resolver looks +up its record and maps fields into the same identity attributes as `identity/jwt`. + +**File directory:** + +```yaml +plugins: + - name: api-keys + kind: identity/api-key + hooks: [identity.resolve] + config: + credential: + kind: header + name: X-API-Key + provider: + kind: file + path: /etc/cpex/keys.yaml + refresh_secs: 30 + max_staleness_secs: 300 + record_map: + subject: + id: user + roles: groups + +global: + authentication: [api-keys] +``` + +The file stores SHA-256 digests, not plaintext keys. Use high-entropy keys to +resist offline guessing if the file leaks: + +```yaml +keys: + - hash: "sha256:59ecf12ac9ef5ee967ddd8aa324f39c7fe13f2a54fb76e3e69bc866887cff903" + user: alice + groups: [reader, writer] + tenant: acme + expires_at: 2027-01-01T00:00:00Z +``` + +Generate a digest with `printf %s "$KEY" | sha256sum` and add the `sha256:` prefix. + +**HTTP directory:** This example uses the RHOAI MaaS `maas-api` validate endpoint. + +```yaml +plugins: + - name: maas-keys + kind: identity/api-key + hooks: [identity.resolve] + capabilities: [perform_http] + config: + credential: + kind: header + name: Authorization + prefix: "Bearer sk-oai-" + provider: + kind: http + url: "https://maas-api.example.com/internal/v1/api-keys/validate" + timeout_secs: 5 + record_map: + subject: + id: username + roles: groups + claims: + exclude: [userId, keyId, keyName] + cache: + ttl_secs: 60 + negative_ttl_secs: 10 + +global: + authentication: [maas-keys] +``` + +`maas-api` sets both `userId` and `keyId` to the key's database row ID. Map +`username` to `subject.id` instead. A captured response uses a Kubernetes +service account name (`system:serviceaccount:example-tenant:example-consumer`) +and Kubernetes groups. These are subject values; `caller_workload` requires a +SPIFFE ID. + +Group names containing `:` cannot be used in attribute paths such as +`role.system:authenticated`. Use the canonical set: + +```apl +require(subject.roles contains "system:authenticated") +``` + +An invalid key returns HTTP 200 with `valid: false`; a failed directory request +denies as `auth.directory_unavailable`. In `prefix: "Bearer sk-oai-"`, the +`Bearer` scheme is matched without case and removed. The `sk-oai-` prefix stays +in the key sent to the directory. To accept several key populations on one +route, configure a resolver for each prefix; nonmatching resolvers skip their +directory lookup. + +The presented key is not stored in `raw_credentials` for downstream forwarding. + +#### Denial codes + +| Code | Means | +|---|---| +| `auth.missing_credential` | nothing at the configured location | +| `auth.empty_credential` | the location held an empty value | +| `auth.key_unknown` | the directory answered, and no record matched | +| `auth.key_expired` | a record matched and has expired | +| `auth.directory_unavailable` | the directory could not answer | +| `auth.mapping_failed` | a record resolved and the map could not project it | + +#### The revocation window + +A revoked key may still authenticate until the file's next successful reload +(`refresh_secs`) or the HTTP cache entry expires (`cache.ttl_secs`). If a file +reload fails, old records remain in use until `max_staleness_secs`; after that, +lookups deny as `auth.directory_unavailable`. A cache cannot be configured over +a file directory. + +The MaaS mapping was checked against a captured valid response on 2026-09-24. +HTTP error handling follows the `maas-api` source; error responses were not +captured from the deployment. + +## Reading claims the way your IdP writes them + +The JWT identity plugin infers a subject from a token's claims. Which +claims, and where they sit, differs by IdP, so the mapping is +configuration rather than something to patch a crate for. The full +reference is [Identity Claim Mapping]({{< relref "/docs/identity-claim-mapping" >}}). + +Name one of the five shipped presets: + +```yaml +claim_mapper: keycloak # standard | keycloak | auth0 | cognito | ibmverify +``` + +Or write the map inline, with candidate paths tried in order: + +```yaml +claim_map: + subject: + roles: + paths: + - realm_access.roles + - resource_access.my-api.roles + merge: union # first_match (default) | union + permissions: + paths: + - { path: permissions, array_only: true } + - scope + split: whitespace # break a delimited string into elements + on_missing: deny # ignore (default) | deny +``` + +`.` separates path segments and `\` escapes one; every other +character, `:` and `/` included, is a literal. `cognito:groups` is +one segment written plainly, and escaping the colon is rejected rather +than quietly accepted. A field whose candidates all miss is left empty +and logged, naming every path tried; `on_missing: deny` makes that a +refusal instead. + +Each preset records what it omits. The Keycloak preset maps +`realm_access.roles`; roles under `resource_access..roles` require a +custom map because the client ID varies by deployment. Auth0 roles also require +a custom map because their namespace is deployment-specific. Presets leave +fields empty rather than assign the wrong concept: Keycloak's `groups` can +contain realm roles, and Cognito's `cognito:roles` contains IAM role ARNs. +The `ibmverify` preset accepts scalar or array values for tenant-provisioned +roles, teams, permissions, client roles, and authorized scopes. Verify can emit +a scalar when one value is present; the `standard` preset reads these collections +as arrays and would leave a scalar value empty. + +Naming no mapper resolves to `standard`, so an existing configuration +is unaffected. + +### Gating on which IdP minted a token + +Registered claims are dropped from the policy-visible bag by default. +That left a deployment trusting several issuers unable to gate on +which one signed. `claims:` overrides that, and is a sibling of both +the preset and the inline map: + +```yaml +claim_mapper: keycloak +claims: + include: [iss] # keep one the inference drops + exclude: [internal_debug] # drop an otherwise-visible claim +``` + +`claim.iss` is then readable from policy. Both lists take top-level +claim names, since the bag is keyed by name: a dotted entry is refused +at load rather than matching nothing, and a claim whose own name holds +a dot is written with `\.`. A `role: caller_workload` resolver carries +no claims bag and says so at load rather than ignoring the setting. + +## Caching a delegated token + +A token exchange is a network call on the request path. The optional +`cache:` block holds a minted token until it expires: + +```yaml +cache: + enabled: true + subjects: [this_workload, client] + max_entries: 10000 + ttl_ceiling_seconds: 300 +``` + +The default subjects are `this_workload` and `client`, which have +bounded cardinality. `user` and `caller_workload` are opt-in through +`cache.subjects` for exactly that reason: one entry per user is not a +cache, it is a leak with an eviction policy. + +Concurrent misses for one key share a single exchange rather than +stampeding the IdP, and a failure is never cached. +`ttl_ceiling_seconds` bounds how long a cached token can outlive a +revocation at the IdP, which is the real cost of caching here: the IdP +can revoke, and a cached token does not hear about it until the ceiling +expires. + +### A route that delegates an unvalidated credential + +A `delegate` step whose subject exchanges the caller's own token +relies on identity resolution having checked that token. But +`authentication:` is per-route and optional, so a route can reach the +delegator with a credential this process never validated. + +Loading reports it, under `alarm = "delegation_without_identity_resolution"`, +naming the route and the delegate plugins on it. `subject: this_workload` +is excluded, since it carries no inbound credential. + ## Where to place CPEX -See [Deployment → Placement guidance]({{< relref "deployment" >}}). -The identity-specific read: +See [Deployment → Placement guidance]({{< relref "/docs/deployment" >}}). The identity-specific +read: | Placement | Use it when | Because | |---|---|---| -| **In front of the tools** (proxy / gateway) | agents are untrusted; you want one chokepoint | CPEX becomes the trust boundary that holds downstream authority; agents can't bypass it | -| **In the MCP / tool server** | defense in depth, or no proxy in the path | the resource enforces even if a front door is skipped; validates the caller right at the data | -| **Agent-side** | the agent is trusted and you want it to self-limit | least-privilege hygiene at the source, not a control against a compromised agent | +| In front of the tools (proxy / gateway) | agents are untrusted; you want one chokepoint | CPEX becomes the trust boundary that holds downstream authority; agents can't bypass it | +| In the MCP / tool server | defense in depth, or no proxy in the path | the resource enforces even if a front door is skipped; validates the caller right at the data | +| Agent-side | the agent is trusted and you want it to self-limit | least-privilege hygiene at the source, not a control against a compromised agent | -You can run CPEX at more than one point at once (agent hygiene + a chokepoint + -resource defense-in-depth): same policy, different boundaries. +Run CPEX at multiple points to combine agent-side restriction, a gateway +chokepoint, and resource defense in depth. Each placement uses the same APL +policy at a different boundary. -## IdP support: tested vs. should-work +## IdP support matrix -CPEX's identity/delegation plugins are configured against OAuth/OIDC **standards**, -not any one vendor. Per layer: +CPEX's identity/delegation plugins are configured against OAuth/OIDC +standards, not any one vendor. Per layer: | Capability | Standard | Tested | Should work (untested) | |---|---|---|---| @@ -429,24 +711,35 @@ not any one vendor. Per layer: | On-behalf-of exchange | RFC 8693 | Keycloak (STE v2) | IdPs vary in RFC 8693 support; verify per target | | SVID as client credential | RFC 7523 + `draft-ietf-oauth-spiffe-client-auth` | Keycloak 26.6 (`spiffe:v1`) | emerging; an IETF OAuth WG draft, other IdPs not yet confirmed | -**If your IdP doesn't yet speak SPIFFE**, CPEX can still validate -the SVID *itself* (it already fetches SPIRE's JWKS in `identity.resolve`), establish -`caller_workload`, and then mint the downstream token using its *own* credentials -(`subject: this_workload`). Note the trade-off: a `client_credentials` grant carries -no caller identity, so the minted token speaks for CPEX, not the agent. Capture -the caller at the CPEX boundary (audit) if the backend needs it. That -"CPEX-validates, CPEX-mints-as-itself" mode works with any OIDC IdP today; only the -recipe-2 *native* flow needs the IdP to understand SVIDs. - -> Testing note: "Tested" means we have run it end-to-end against that IdP. -> "Should work" means it relies only on standards that IdP documents supporting. -> Treat it as a starting point, not a guarantee, and tell us what you find. +If the IdP does not support SPIFFE, CPEX can still validate +the SVID *itself* (it already fetches SPIRE's JWKS in `identity.resolve`), +establish `caller_workload`, and then mint the downstream token using its *own* +credentials (`subject: this_workload`). This changes attribution: a +`client_credentials` grant carries no caller identity, so the minted token +speaks for CPEX, not the agent. Capture the caller at the CPEX boundary (audit) if +the backend needs it. That "CPEX-validates, CPEX-mints-as-itself" mode works with +any OIDC IdP today; only the recipe-2 *native* flow needs the IdP to understand +SVIDs. -## What to add next +"Tested" means the flow passed an end-to-end test against that IdP. "Should +work" means the IdP documents support for the required standards, but the flow +has no end-to-end result. + --> + +## Next + +- [Identity Claim Mapping]({{< relref "/docs/identity-claim-mapping" >}}): map IdP claims onto + subject roles, permissions and teams. +- [Header Assertions]({{< relref "/docs/assertions" >}}): project resolved identity onto upstream + requests. +- [Deployment]({{< relref "/docs/deployment" >}}): place identity and delegation controls at the + required boundary. diff --git a/docs/content/docs/migration-0.3.md b/docs/content/docs/migration-0.3.md new file mode 100644 index 00000000..e83b8f53 --- /dev/null +++ b/docs/content/docs/migration-0.3.md @@ -0,0 +1,491 @@ +--- +title: "Migrating to 0.3" +weight: 125 +--- + +# Migrating from 0.2.x to 0.3 + +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: + +1. [Rust](#rust-api), [Python](#python-api), [Go](#go-api) or [C ABI](#c-abi-v3): rename the API calls for your surface. +2. [Hook names](#hook-names): rename `hooks:` entries and hook names your code invokes. +3. [Config keys](#config-keys): rewrite the policy document. +4. [Capabilities](#new-required-capability-perform_http), [dispatch](#dispatch-defaults-to-policy) and [egress](#outbound-http-and-private-destinations): the behavior changes that load fine but act differently. +5. [Builtins](#builtins-added-and-removed) and [secrets](#secret-refresh-is-the-hosts-job): the bundled set changed. + +Load the migrated config through your own host. A 0.3.0 load names every key, hook and plugin it rejects, so read the whole error before fixing the first line. + +PPE 0.5.0 also validates OAuth token responses more strictly: issued token type, granted scopes and JWT audience/subject are checked by default. Delegated-token keys include the delegator instance and logical target. Review IdP responses and policies that depend on token defaults. Secret assertions add `HttpExtension::secret_headers`; exhaustive Rust literals must initialize it and hosts must preserve its provenance between hooks. JSON float parsing uses `float_roundtrip`, so numeric boundaries and re-serialized audit records can change. See the [PPE 0.5.0 release notes](https://github.com/praxis-proxy/policy/releases/tag/v0.5.0) for the full Rust API changes. + +## Rust API + +| 0.2.x | 0.3.0 | +|---|---| +| `cpex::PluginManager` | `cpex::PolicyEngine` | +| `ManagerConfig` | `cpex::praxis_policy_core::engine::PolicyEngineConfig` | +| `cpex_sdk::*` | `cpex::prelude::*` | +| `cpex_core::factory`, `cpex_core::hooks::adapter`, `cpex_core::cmf` | `cpex::prelude::*` | +| `cpex_core::executor`, `::extensions`, `::identity`, `::elicitation` | `cpex::praxis_policy_core::executor`, `::extensions`, `::identity`, `::elicitation` | +| `cpex::PipelineResult` | `cpex::praxis_policy_core::executor::PipelineResult` | +| `apl_core`, `apl_cmf`, `apl_cpex` | `cpex::praxis_policy_apl_core`, `cpex::praxis_policy_apl_cmf`, `cpex::praxis_policy_apl_runtime` | +| `PluginSettings`, `PolicyConfig::plugin_settings` | `EngineSettings`, `PolicyConfig::engine_settings` | +| `routing_enabled()` | `dispatch_mode()` | +| `Phase::Policy`, `Phase::PostPolicy` | `Phase::PreInvocation`, `Phase::PostInvocation` | +| `CompiledRoute.policy`, `.post_policy` | `.pre_invocation`, `.post_invocation` | +| `.identity` on `GlobalConfig`, `PolicyGroup`, `RouteEntry` | `.authentication` | +| `compile_config`, `ConfigYaml`, `CompiledConfig` | removed; use `compile_policy_block_value` | +| `HOOK_CMF_HTTP_REQUEST`, `HOOK_CMF_HTTP_RESPONSE` | `praxis_policy_core::http_hook::HOOK_HTTP_REQUEST`, `HOOK_HTTP_RESPONSE` | +| `invoke_named::` on an HTTP hook | `invoke_named::` with `HttpPayload` | +| `Subject::claims: HashMap` | `HashMap` | + +`install_builtins` keeps its name. Two new calls sit beside it, because the builtins that make HTTP calls now borrow a transport from the host: + +```rust +use std::sync::Arc; +use cpex::PolicyEngine; + +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +cpex::install_default_http_transport(&engine); // or engine.set_http_transport(...) +engine.load_config_yaml(yaml)?; +engine.initialize().await?; +``` + +Without a transport, a plugin that needs one fails at `initialize()`, naming the missing host service. See [Outbound HTTP](#outbound-http-and-private-destinations). + +Call `install_builtins` exactly once per engine. PPE 0.5.0 appends an APL visitor on every call and discards its handle, so a second install is a Rust host error and can process policy sections twice. `cpex::host::install_host_defaults` has the same once-per-engine requirement. The C/Go and Python bindings retain their own idempotency guards. The [upstream follow-up, PPE #208](https://github.com/praxis-proxy/policy/issues/208) requests an idempotent PPE helper; keep the host guard until that fix ships. + +### Facade features + +The facade features `builtins`, `full`, `jwt`, `oauth`, `elicitation-ciba`, `cedar`, `cel`, `valkey`, `pii` and `audit` are gone. Every stable builtin is always compiled in. The only feature left is `python-host`. + +```toml +# 0.2.x +cpex = { version = "0.2", features = ["builtins"] } +# 0.3.0 +cpex = "0.3" +``` + +### Removed crates + +`cpex-core`, `cpex-sdk`, `cpex-orchestration`, `cpex-builtins`, `apl-core`, `apl-cmf`, `apl-cpex`, `cpex-plugin-pii-scanner`, `cpex-plugin-audit-logger`, `cpex-plugin-identity-jwt`, `cpex-plugin-delegator-oauth`, `cpex-plugin-elicitation-ciba`, `cpex-pdp-cedar-direct`, `cpex-pdp-cel`, `cpex-pdp-opa` and `cpex-session-valkey` are no longer built here. Their last release, 0.2.3, stays on crates.io and gets no further updates. Replace each dependency with `cpex` (or with `praxis-policy-core` for a plugin crate). Do not keep one of them beside `cpex` 0.3: the two engines do not share types. + +### Plugin errors + +A panic in a `sequential` or `transform` plugin is contained and reported as a violation with the code `plugin_panic`, so it denies like any other failed plugin under `on_error: fail`. A panicking `parallel:` branch is a deny. + +## Python API + +| 0.2.x | 0.3.0 | +|---|---| +| `from cpex import PluginManager` | `from cpex import PolicyEngine` | +| `PluginManager(config_path)` | `PolicyEngine(config_path, *, allow_private_destinations=False)` | +| (none) | `await engine.refresh_secrets()` | +| (none) | `engine.secrets_last_success(provider)` | + +`PluginManager` remains as an alias (`PluginManager is PolicyEngine`), so existing imports keep working. New code uses `PolicyEngine`. + +The constructor installs the builtins, one HTTP transport and the `env`, `file` and `vault` secret providers, then loads the config. `allow_private_destinations` is keyword-only; see [Outbound HTTP](#outbound-http-and-private-destinations). `invoke_hook` now needs `meta.entity_type` and `meta.entity_name` in its extensions to select a route; see [Dispatch](#dispatch-defaults-to-policy). + +`validator/pii-scan` and `audit/logger` no longer resolve in the wheel. A config naming them fails to load with an unknown-kind error. See [Builtins removed](#removed). + +`identity.resolve` takes a typed `IdentityPayload` dict: pass `{"source": "bearer", "headers": {"authorization": f"Bearer {token}"}}` with lowercased header names, plus the route's `meta` in the extensions. The resolved subject comes back in `modified_payload["subject"]`; copy it into `extensions["security"]["subject"]` for the tool call. See [Identity input](#identity-input) for the fields an input may set and what the result leaves out. Delegation (`delegate(...)` steps, `token.delegate`) is not available from Python, because inbound tokens do not cross back from an `identity.resolve` result. Drive delegation from Rust, or from Go with `InvokeResolved`. + +The lifecycle calls are stricter: + +- Calling `shutdown()` starts teardown immediately and refuses new work. Await its result to know when teardown has finished; dropping or cancelling the awaitable does not stop the shutdown task. It waits for an `initialize()` in flight, then tears down the plugins it started. One that raised `TimeoutError` can be retried, and the retry waits for the first to finish. +- An `initialize()` that raises `TimeoutError` keeps running; once it ends, the engine shuts down to stop the plugins it started. The engine is terminal from the timeout on, so build a new `PolicyEngine` to retry. +- `refresh_secrets()` raises `RuntimeError` when a secret provider panics. + +## Go API + +The Go module and the FFI artifact must come from the same CPEX release. The package checks the ABI version at init and panics on a mismatch, naming both versions. + +| 0.2.x | 0.3.0 | +|---|---| +| `cpex.PluginManager` | `cpex.PolicyEngine` | +| `cpex.NewPluginManager(yaml)` | `cpex.NewPolicyEngine(yaml)` | +| `cpex.NewPluginManagerDefault()` | `cpex.NewPolicyEngineDefault()` | +| `mgr.EnableAPL()` | `engine.EnableAPL(opts ...APLOption)` | +| (none) | `cpex.WithPrivateDestinations()` | +| (none) | `engine.RefreshSecrets()` | +| (none) | `engine.SecretsLastSuccess(provider)` | +| `SubjectExtension.Claims map[string]string` | `map[string]any`: a claim keeps its JSON shape, so type-assert to read it | +| `SecurityExtension.Agent *AgentIdentity` | removed; use `ThisWorkload *WorkloadIdentity` (`SpiffeID`, `TrustDomain`, `ClientID`) | +| (none) | `SecurityExtension.Client`, `.CallerWorkload`, `Extensions.CandidateConstraint` | +| `Resource.Blob []byte` | `ByteArray`; a `[]byte` value still assigns, and it matches the Rust wire format | +| `DataPolicy.AllowedActions` empty allowed every action | nil allows every action; an empty non-nil slice allows none | + +`EnableAPL` now also installs the HTTP transport and the secret providers. It is idempotent after a success: a second call does nothing, whatever its options. A call that panicked stays failed: it and every later call return an error wrapping `ErrCpexPanic`, because the engine may be half installed. Discard the engine and create a new one. `InvokeByName` callers must pass the new hook names (see [Hook names](#hook-names)), and an `identity.resolve` call must carry the route's `Meta` (entity type and name) like any other hook. + +A plugin panic during `Invoke` now returns a nil error and a denied result whose violation code is `plugin_panic`. `ErrCpexPanic` is reserved for a panic in the FFI layer itself. + +`LoadConfig` returns a typed error (for example a parse or pipeline error) without the engine's message text. The full message, naming the rejected key, hook or plugin, goes to the `tracing` log, so route the FFI log somewhere you can read while migrating a config. + +## C ABI v3 + +`FFI_ABI_VERSION` is 3. A host built against ABI 2 fails its version check. + +| ABI 2 | ABI 3 | +|---|---| +| `cpex_manager_new(yaml, len)` | `cpex_engine_new(yaml, len)` | +| `cpex_manager_new_default()` | `cpex_engine_new_default()` | +| `CpexManagerInner` (`.manager`) | `CpexEngineInner` (`.engine`) | +| `cpex_apl_install(mgr)` | `cpex_apl_install(handle, flags)` | +| (none) | `cpex_refresh_secrets(handle, &report, &len)` | +| (none) | `cpex_secrets_last_success(handle, provider, len, &unix_ms)` | + +The other entry points (`cpex_load_config`, `cpex_initialize`, `cpex_invoke`, `cpex_invoke_resolved`, `cpex_shutdown` and the free functions) keep their names and take the engine handle. + +A plugin panic inside `cpex_invoke` or `cpex_invoke_resolved` now returns `RC_OK` with a denied result whose violation code is `plugin_panic`. `RC_PANIC` is reserved for a panic in the FFI layer itself. + +`cpex_apl_install` installs the builtins, one HTTP transport and the `env`, `file` and `vault` secret providers, with Vault on the same transport. `flags` is a bit set: + +| Flag | Value | Meaning | +|---|---|---| +| `CPEX_APL_ALLOW_PRIVATE_DESTINATIONS` | `1` | let builtins reach private, loopback and link-local addresses | + +Any other bit returns `RC_INVALID_INPUT`. The call is idempotent per handle after a success: a second call returns `RC_OK` and changes nothing. After a call that panicked, every later call returns `RC_PANIC`; free the handle and create a new one. `cpex_engine_new(yaml)` still registers no builtins and no transport. + +`cpex_refresh_secrets` returns a MessagePack map `{updated, unchanged, failed}`; free it with `cpex_free_bytes`. `cpex_secrets_last_success` writes Unix milliseconds, or `0` when the provider has never refreshed successfully or the name is unknown. Failed refreshes retain the last successful timestamp; inspect `failed` to detect an outage. + +## Hook names + +The legacy unprefixed hook names are removed. A config that names one fails to load, and the error names the replacement. + +| 0.2.x | 0.3.0 | +|---|---| +| `tool_pre_invoke` | `cmf.tool_pre_invoke` | +| `tool_post_invoke` | `cmf.tool_post_invoke` | +| `prompt_pre_fetch`, `cmf.prompt_pre_fetch` | `cmf.prompt_pre_invoke` | +| `prompt_post_fetch`, `cmf.prompt_post_fetch` | `cmf.prompt_post_invoke` | +| `resource_pre_fetch` | `cmf.resource_pre_fetch` | +| `resource_post_fetch` | `cmf.resource_post_fetch` | +| `identity_resolve` | `identity.resolve` | +| `token_delegate` | `token.delegate` | +| `cmf.http_request` | `http.request` | +| `cmf.http_response` | `http.response` | + +Unchanged: `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`, `cmf.llm_input`, `cmf.llm_output`, `cmf.resource_pre_fetch`, `cmf.resource_post_fetch`, `identity.resolve`, `token.delegate` and `elicit`. + +This applies to three places: `hooks:` lists in config, Go `InvokeByName` and Python `invoke_hook` calls, and Python plugins run through the Python host (see [Python plugin host](#python-plugin-host)). + +A removed name is also refused at invoke time. It would reach no handler and come back allowed with no policy evaluated, so Go `InvokeByName` and `InvokeResolved` return `ErrCpexInvalidInput`, Python `invoke_hook` raises `ValueError` naming the replacement, and C `cpex_invoke` and `cpex_invoke_resolved` return `RC_INVALID_INPUT`. + +### Identity input + +An `identity.resolve` input may set only `source`, `source_header`, `headers`, `client_host` and `client_port`. The other fields (`subject`, `client`, `caller_workload`, `delegation`, `raw_credentials`, `resolved_at`, `raw_claims`) are what resolvers fill in, and a caller that pre-filled them could forge the resolved identity. An input that sets one is refused: Python raises `ValueError`, Go returns `ErrCpexInvalidInput`, and the C ABI returns `RC_INVALID_INPUT`. This covers `invoke_hook("identity.resolve", ...)`, `InvokeByName(HookIdentityResolve, ...)`, `cpex_invoke` and the identity payload of `InvokeResolved` / `cpex_invoke_resolved`. + +The result does not echo the request `headers`. They carry the credential header, so a host that logged or forwarded the result would leak the inbound token. Read the resolved fields (`subject`, `client`, `raw_claims` and the rest) from the result, and keep the headers you sent if you need them. + +## Config keys + +A 0.3.0 load rejects every key it does not know, at every scope, and names the replacement where one exists. A config that loads has no inert keys left. + +### `global.apl:` is gone + +```yaml +# 0.2.x +global: + apl: + pdp: [ ... ] + session_store: { kind: valkey, endpoint: localhost:6379 } + attribute_files: [ ./data/tenants.yaml ] +``` + +```yaml +# 0.3.0 +global: + pdp: [ ... ] + session_store: { kind: valkey, endpoint: localhost:6379 } + attribute_files: [ ./data/tenants.yaml ] +``` + +`pdp:`, `session_store:` and `attribute_files:` are valid only under `global:`. + +### The `apl:` wrapper is gone everywhere + +APL terms sit on the section that carries them: a route, `global:`, `global.defaults.:` or a group. `apl:` is an unknown key at every scope. + +```yaml +# 0.2.x +routes: + - tool: get_compensation + apl: + authorization: + pre_invocation: ["require(authenticated)"] + result: + ssn: "redact(!perm.view_ssn)" +``` + +```yaml +# 0.3.0 +routes: + - tool: get_compensation + authorization: + pre_invocation: ["require(authenticated)"] + result: + ssn: "redact(!perm.view_ssn)" +``` + +### Phase lists nest under `authorization:` + +Flat `pre_invocation:` and `post_invocation:` on a route are load errors. Nest them under `authorization:`. + +An `authorization:` block that contributes no step also fails the load ("contributes no step"). This covers `authorization: {}` and every phase written as an empty list. An open route says so explicitly: + +```yaml +routes: + - tool: search_repos + authorization: + pre_invocation: ["allow"] +``` + +### Renamed and removed keys + +Each key below fails the load and the error names what replaced it. + +| 0.2.x key | Scope | 0.3.0 | +|---|---|---| +| `policy:` | any | `authorization.pre_invocation:` | +| `post_policy:` | any | `authorization.post_invocation:` | +| `identity:` | any | `authentication:` | +| `policies:` (`global.policies:`) | `global:` | top-level `groups:` | +| `plugin_settings:` | top level | `engine_settings:` | +| `routing_enabled: true` | `plugin_settings:` | `dispatch: policy` (the default) | +| `when:` | a route | a `when:` / `do:` step under `authorization:` | +| `plugin_dirs:` | top level | `register_factory()` plus a `plugins:` declaration | +| `parallel_execution_within_band` | `plugin_settings:` | `mode: concurrent` on the plugin | +| `fail_on_plugin_error` | `plugin_settings:` | `on_error: fail` on the plugin | +| `on_error:` | an `authentication:` step | `on_error:` on the plugin's own `plugins:` entry | +| `args:`, `result:` | `global:` | write them on the route or on `global.defaults.:` | + +A group written under top-level `groups:` resolves exactly as one under `global.policies:` did. + +### `plugin(name)` is `run(name)` + +`plugin(name)` is rejected in step and stage position. Write `run(name)`. `plugin:` as a keyword inside `delegate(...)` still parses. + +### Route `plugins:` lists + +Under the default `dispatch: policy`, a list-form `plugins:` on a route, group, entity default or the `all` bundle is a load error. Name the plugin from a step instead: + +```yaml +# 0.2.x +routes: + - tool: get_compensation + plugins: [audit-log] +``` + +```yaml +# 0.3.0 +routes: + - tool: get_compensation + authorization: + pre_invocation: ["run(audit-log)"] +``` + +For a plugin that ran on every request, write one `run(...)` step under `global.authorization:`. A `plugins:` map (per-plugin overrides of `config`, `capabilities` or `on_error`) still works. + +### `priority:` and `conditions:` belong to hook dispatch + +Under `dispatch: policy`, a per-plugin `priority:` or `conditions:` is a load error, because a step names the one plugin it runs. Order the steps instead. Both stay valid under `engine_settings: { dispatch: hooks }`, where `routes:`, `groups:` and `global:` are load errors in turn. + +### Policy text + +- A quoted literal processes exactly three escapes: `\\`, `\'` and `\"`. A regex class needs a doubled backslash: `regex("\\d+")`. A single backslash fails the load. +- An empty stage in a pipe chain (`"redact(...) |"`) fails the load. +- Malformed attribute paths (`a..b`, `a.`, `data.t[]`) fail the load instead of reading as absent. +- `not` is reserved (write `!`), and `&&` / `||` fail naming `&` / `|`. +- `require(a, b | c)` now parses as `!(a & (b | c))`. A config that was rejected for mixing `,` and `|` may now load; check what it decides. + +PPE's [APL upgrade guide](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/upgrade-apl.md) covers each rule in detail. + +## New required capability: `perform_http` + +A plugin that reaches outside the process must declare `perform_http`: + +- every `identity/jwt` plugin with a `jwks_url` decoding key, +- every `delegator/oauth` plugin, +- every `elicitation/ciba` plugin, +- an `identity/api-key` plugin with an `http` directory. + +```yaml +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: { ... } +``` + +A missing capability passes the load and fails at `initialize()` or on the first request that needs the call, with an error naming the plugin and the capability. Withholding it stops the call; it never degrades to an unchecked token. + +## Dispatch defaults to `policy` + +`engine_settings.dispatch` defaults to `policy`. Under it, a plugin runs only where a policy reaches it: a `run(name)` step, an `authentication:` list, a `delegate(...)` call or an elicitation verb. What this means in practice: + +- **Identity runs only on routes that list it.** A route without `authentication:` (at route, group, entity default or global scope) does not run identity plugins, so no subject, `authenticated` or inbound credential reaches it. In 0.2.3 identity ran on every route. A route that reads `subject.*`, `role.*`, `authenticated` or that delegates must list `authentication:`. The simplest fix is `global: { authentication: [jwt-user] }`. +- **Requests need entity metadata.** Hosts select a route with `meta.entity_type` and `meta.entity_name`. A request without them resolves no route and is denied with `unidentified_request` (a 400-class code), as long as the config declares any policy. Python and Go callers pass them in the extensions; the [quick start]({{< relref "/docs/quickstart" >}}) shows the shape. +- **A config that only declares `plugins:`** and relied on all of them firing must write `engine_settings: { dispatch: hooks }`. + +## Load-time failures + +0.3.0 refuses at load several configs 0.2.x accepted: + +- **Unreached plugins.** Under `dispatch: policy`, a declared plugin no policy reaches fails the load, by name. Reference it or drop it. A plugin reached on fewer hooks than it declares only logs a warning (`plugin_narrowed_by_policy`). +- **Unknown keys** at any scope, including misspellings inside `global:`, groups, `authentication:` steps and the JWT plugin's own config. +- **Unknown or removed hook names** in `hooks:`, with the nearest valid name. +- **An unknown plugin `kind`**, including `validator/pii-scan` and `audit/logger`. +- **A JWT issuer with no `audiences`.** An empty list used to disable the audience check. Set `skip_audience_validation: true` only if you mean it. +- **A delegating route with no identity resolution** is reported (`delegation_without_identity_resolution`) so you can add `authentication:`. + +## Outbound HTTP and private destinations + +The engine makes no HTTP calls of its own. Builtins that fetch JWKS, exchange tokens, poll CIBA or read Vault borrow one transport from the host. Every CPEX surface installs the bundled transport for you, except the plain Rust facade where you call `install_default_http_transport` or `set_http_transport`. + +The bundled transport **refuses private destinations by default**. Each surface has one opt-in, set in host code. No policy document can turn it on. + +| Surface | Opt-in | +|---|---| +| Rust | `HyperTransport::new().with_allow_private_destinations()`, installed with `engine.set_http_transport(...)` | +| Python | `PolicyEngine(path, allow_private_destinations=True)` | +| Go | `engine.EnableAPL(cpex.WithPrivateDestinations())` | +| C ABI | `cpex_apl_install(handle, CPEX_APL_ALLOW_PRIVATE_DESTINATIONS)` | + +> **Blast radius.** The opt-in is not scoped to one plugin or host name. It permits loopback, RFC 1918 ranges, link-local addresses (including the cloud metadata endpoint at `169.254.169.254`) and CGNAT for every outbound builtin the engine runs: JWKS fetches, token exchange, CIBA and Vault. A policy author who can point a plugin at a URL can then reach anything on the host's network. The tutorial and the demos turn it on so they can reach a local IdP. Those settings are not for production. In production, reach in-cluster services through a host transport with its own egress rules (`set_http_transport`), or keep the default. + +The C/Go and Python binding installers emit a startup warning through the host's tracing subscriber, or stderr if none is installed, when the opt-in is enabled. The warning names the shared scope, cloud metadata and CGNAT. The tutorial emits it too. Rust hosts using `HyperTransport` directly can call `cpex::host::warn_private_destinations()`. A private-host allow-list is [deferred upstream in PPE #209](https://github.com/praxis-proxy/policy/issues/209); PPE 0.5.0 has no narrower switch. + +What a refused call looks like: + +| Builtin | Symptom | +|---|---| +| `delegator/oauth` | `delegation.egress_denied` | +| `elicitation/ciba` | `elicitation.egress_denied` | +| `identity/jwt` (`jwks_url`) | `auth.jwks_unavailable`: the request is denied as if the IdP were down | +| `vault` secret provider | `initialize()` fails with a backend error naming the value | + +If a JWT route that worked on 0.2.3 now denies with `auth.jwks_unavailable` and the JWKS URL is on `localhost` or a private address, this is the cause. + +## Builtins added and removed + +### Added + +| Kind | Type | Notes | +|---|---|---| +| `opa` | PDP | Embedded Rego. Now in the Python and Go builds too. | +| `elicitation/ciba` | elicitation channel | Now in the Python and Go builds too. | +| `identity/api-key` | identity resolver | Opaque keys against a file or HTTP directory. New. | +| `vault` | secret provider | Vault KV v2. The Python and Go builds install it. A Rust host calls `cpex::registry_with_vault(transport)` and `engine.set_secret_providers(...)`. | + +`env` and `file` secret providers and the top-level `secrets:` block are also new. See [Configuration]({{< relref "/docs/configuration#secrets-and-key-material" >}}). + +### Removed + +`validator/pii-scan` and `audit/logger` are no longer bundled on any surface. A config that names either fails to load with an unknown-kind error. Replacements: + +| 0.2.x | 0.3.0 | +|---|---| +| `run(pii-scan)` in `mode: deny` | a CEL or APL deny step on the arguments you care about (example below) | +| `pii-scan` in `mode: redact` | an APL field pipeline on the field: `"str \| redact"`, or `"str \| redact(!perm.view_ssn)"` to redact by permission | +| `audit/logger` | a host plugin of your own registered under a `kind` you choose; keep the plugin `name` so `run(audit-log)` steps stay unchanged. See [Auditing]({{< relref "/docs/auditing" >}}). | + +A deny step that refuses an SSN-shaped value in one argument: + +```yaml +global: + pdp: + - kind: cel + +routes: + - tool: send_email + authorization: + pre_invocation: + - cel: + expr: "!has(args.body) || !args.body.matches('[0-9]{3}-[0-9]{2}-[0-9]{4}')" + on_deny: ["deny('SSN pattern in body', 'pii.detected')"] +``` + +A regex is not data-loss prevention. For real detection, register a host plugin backed by a detector you trust (Presidio, a cloud DLP API) and call it with `run(...)`. + +A Rust host that wants the old behavior can still run PPE's reference implementations. They are `publish = false`, so copy their source into your own workspace and build it against `praxis-policy-core` from crates.io. Do not add them as a git dependency: that builds a second copy of the core crate with types that do not match the one `cpex` links. See [Builtins]({{< relref "/docs/builtins#reference-plugins-in-a-rust-host" >}}). The Python and Go builds cannot load them. + +## Secret refresh is the host's job + +The engine reads every declared secret once, at `initialize()`, and never re-reads on its own. Now that the bindings ship Vault, a binding host that rotates secrets must call refresh itself: + +| Surface | Refresh | Health | +|---|---|---| +| Rust | `engine.refresh_secrets().await` | `engine.secrets_last_success(provider)` | +| Python | `await engine.refresh_secrets()` | `engine.secrets_last_success(provider)` | +| Go | `engine.RefreshSecrets()` | `engine.SecretsLastSuccess(provider)` | +| C ABI | `cpex_refresh_secrets` | `cpex_secrets_last_success` | + +Call refresh on an interval and alarm when a provider's last success is older than you can tolerate. A value that fails to re-read keeps its last good bytes and is listed in the report, so a Vault outage degrades to a stale credential, not to none. The interval is the upper bound on how long a revoked credential stays in use. + +## Python plugin host + +The `isolated_venv` host (`python-host` feature) follows the new hook names. Declare PPE names in config (`cmf.tool_pre_invoke`, `identity.resolve`, `token.delegate`); the host translates them on the wire to the names the Python worker knows. + +The released 0.1.x Python worker registers no `cmf.*` hooks. For a worker that does not claim the `cmf_hooks` capability at spawn time, the host translates each tool, prompt and resource `cmf.*` hook into the legacy hook and typed payload the worker expects (`tool_pre_invoke` with `{name, args}`, and so on), then maps the result back. A 0.1.x plugin keeps working with no SDK release: declare `cmf.tool_pre_invoke` in config and keep implementing `tool_pre_invoke` in Python. + +The translation fails closed with `legacy_shim_unsupported` when it cannot represent a request faithfully. It never allows or skips. A plugin declaring `cmf.llm_input` or `cmf.llm_output` fails initialization unless the worker reports `cmf_hooks`, because those hooks have no legacy equivalents. Shim refusal cases include a message without exactly one matching content part; on a post hook (`cmf.tool_post_invoke`, `cmf.prompt_post_invoke`, `cmf.resource_post_fetch`), any other part that carries text or data, since the plugin cannot see or redact it; a non-string prompt argument; a qualifier a legacy plugin that authorizes by bare name or URI cannot see (a tool call `namespace`, a prompt `server_id`, a resource `version`); a binary resource; an error result the legacy payload cannot mark; and a modified payload that sets something the message cannot hold or would leave stale data behind. The full list is in the [Python host README](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-hosts-python#cmf-hooks-on-workers-without-cmf_hooks-the-legacy-payload-shim). A worker that claims `cmf_hooks` receives the CMF message directly. On a pre hook (`cmf.tool_pre_invoke`, `cmf.prompt_pre_invoke`, `cmf.resource_pre_fetch`), parts beside the one the legacy payload carries, such as narration text beside a tool call, pass through unchanged: the plugin authorizes the part it sees. + +On `identity.resolve` and `token.delegate` the host accepts PPE's `IdentityPayload` and `DelegationPayload`, which PPE's FFI, its Python bindings and the APL `delegate(...)` step build. The plugin keeps reading the Python `IdentityPayload` / `DelegationPayload` it already knows, and its `IdentityResult` / `DelegationResult` maps back onto PPE's payload. + +Two credential rules change: + +- A delegator on PPE's `DelegationPayload` receives the caller's bearer token only when it declares `read_inbound_credentials`, as PPE's own OAuth delegator does. A plugin that declared only `read_delegated_tokens` now gets an error instead of the token. Add `read_inbound_credentials` to its `capabilities:`. +- A Python worker plugin that does not declare `read_inbound_credentials` receives no raw request header values in the public identity payload. The out-of-process `http` extension also sends empty request and response header maps by default, even with `read_headers`; its request-line fields still cross. A plugin that declares `read_inbound_credentials` receives the full identity header map through the gated credential object. + +## What each surface can do + +The engine is the same everywhere, but not every feature reaches every surface in 0.3.0: + +| Feature | Rust | Python | Go | +|---|---|---|---| +| `http:` routes and the `http.request` / `http.response` hooks | yes | no: the HTTP hooks expect an `HttpPayload` the binding cannot build | no: the FFI has no payload type for the HTTP hooks | +| Delegation (`delegate(...)`, `token.delegate`) | yes | no | yes, with `InvokeResolved` | +| Audit sinks next to `routes:` | no (see below) | no | no | +| Registering your own plugin factories | yes | no | yes, through a linked Rust FFI crate | + +**Audit sinks and routes.** Under `dispatch: policy`, PPE 0.5.0 fails the load for a sink no step names, and `run()` denies because a sink has no hook handler to call. Until PPE changes this, a config with routes cannot attach an audit sink. Use a hook plugin that a `run(...)` step names, or log the `PipelineResult` each call returns (it carries the verdict and violation code). See [Auditing]({{< relref "/docs/auditing" >}}). + +**Host-supplied client identity.** A host can set `security.client` in the extensions it passes, but route rules see `client.*` only when a plugin the route references declares `read_client`. A route whose rules read `client.*` with no such plugin reads absent values. Resolve the client with an `identity/jwt` plugin (`role: client`) on the route, or reference a plugin that declares `read_client`. See [CMF Extensions]({{< relref "/docs/cmf-extensions" >}}). + +**JWT deny codes.** `identity/jwt` reports `auth.token_expired`, `auth.signature_invalid`, `auth.malformed*` and `auth.jwks_unavailable`, among others. A route that matched on 0.2.x reason text should match on these codes. + +## Intended behavior differences + +The parity suite replays 0.2.3 decision snapshots through 0.3.0. Every request reaches the same decision. These differences are expected: + +| Difference | Cause | +|---|---| +| Rule-path deny codes drop the `.apl.` segment: `routes.tool:x.apl.pre_invocation[0]` is now `routes.tool:x.pre_invocation[0]` | The `apl:` wrapper is gone. Same decision, same step index. Steps with a custom code are unaffected. | +| An OAuth token-exchange denial reason no longer carries the IdP's `error_description`, only the OAuth `error` code (`token exchange rejected: invalid_target`) | Security fix: an IdP may echo the submitted token in its description. The violation code is unchanged. | +| A token with an unknown `kid` triggers one JWKS refetch, rate limited by `min_refresh_interval_secs` | JWKS refresh is on demand instead of on a timer. The token is still rejected with the same code if the key is not there. | +| A route without `authentication:` does not run identity plugins, so no subject or inbound credential reaches it | The [dispatch default](#dispatch-defaults-to-policy). | + +Valkey session labels written by 0.2.3 are read back unchanged: the key layout (`sha256(subject : session id)`) is the same. + +Other changes that can alter a decision, from PPE's own changelog: + +- `!=` on a missing attribute is true, so `subject.role != "admin": deny` now fires for a caller with no role. +- A non-numeric or non-finite value in an order comparison (`args.amount > 10000`) fails closed instead of skipping the rule. +- A route that joins a group with `groups:` now inherits the group's `authorization:`, not only its `authentication:`. +- `global.defaults..authentication:` is now applied. +- OPA and CEL read `args` on `tool:` routes as native JSON: number arrays hold numbers, arrays keep order and duplicates, and an explicit `null` is present. Review every OPA or CEL rule that reads `args`. See [PDP Integration]({{< relref "/docs/apl/pdp#migrating-args-policies" >}}). +- Subject claims keep their JSON shape, so a nested claim such as `realm_access.roles` is a set policy can test. + +## CEL notes + +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. + +## Elicitation approvals are bound and single use + +PPE 0.5.0 binds CIBA approvals to the authenticated requester, selected route and tool, and redeems each approval once within the plugin instance. A request with no subject id fails before dispatch. Another subject cannot reuse an approval, and a second redemption is refused. This fixes the approval-reuse limitation present in 0.2.3 and PPE 0.4.1. + +Keep approval `timeout` short and return elicitation ids only to the requesting client. The parity fixture records the intended change from allow to deny when a second subject retries a redeemed id. diff --git a/docs/content/docs/patterns.md b/docs/content/docs/patterns.md index 77d8b5ee..19206629 100644 --- a/docs/content/docs/patterns.md +++ b/docs/content/docs/patterns.md @@ -32,9 +32,16 @@ Before a new policy blocks traffic, run it in `audit` mode to observe what it wo ```yaml plugins: - name: new-policy-check - kind: validator/pii-scan + kind: acme/content-check # a host plugin your program registers + hooks: [cmf.tool_pre_invoke] mode: audit # observe only; flip to sequential to enforce on_error: ignore + +routes: + - tool: send_email + authorization: + pre_invocation: + - "run(new-policy-check)" ``` ## Input and output guardrails @@ -81,4 +88,4 @@ authorization: ## Defense in depth -Combine the patterns: an attribute gate, a PDP relationship check, a PII scan on output, a taint, and an audit record, each a separate effect in one policy. No single layer is load-bearing alone; the operation has to pass all of them. +Combine the patterns: an attribute gate, a PDP relationship check, field redaction on output, a taint, and an audit record, each a separate effect in one policy. No single layer is load-bearing alone; the operation has to pass all of them. diff --git a/docs/content/docs/pipeline.md b/docs/content/docs/pipeline.md index 6e6cd39e..61850c5d 100644 --- a/docs/content/docs/pipeline.md +++ b/docs/content/docs/pipeline.md @@ -9,19 +9,35 @@ APL is the policy surface. The pipeline is what runs underneath it: the mechanis ## Hooks -A **hook** is a named interception point. The host invokes a hook at an operation boundary (before a tool call, after an LLM completion, around a prompt or resource fetch), and the plugin manager runs the plugins registered there. Hooks are where APL routes attach: a route's `policy` phase runs at the pre-invocation hook, its `result` phase at the post-invocation hook. +A **hook** is a named interception point. The host invokes a hook at an operation boundary (before a tool call, after an LLM completion, around a prompt or resource fetch), and the engine runs the plugins registered there. Hooks are where APL routes attach: a route's `authorization.pre_invocation` phase runs at the pre-invocation hook, its `result` phase at the post-invocation hook. -When an effect says `plugin(pii-scan)` or `delegate(workday-oauth)`, it is naming a plugin registered on the relevant hook. The effect is the policy-level intent; the plugin is the code that runs. +When an effect says `run(pii-scan)` or `delegate(workday-oauth)`, it is naming a plugin registered on the relevant hook. The effect is the policy-level intent; the plugin is the code that runs. -## The plugin manager +The hook names a plugin declares in `hooks:` come from a fixed table: -The `PluginManager` owns registration, ordering, capability filtering, timeouts, and error isolation. A plugin can: +| Family | Hooks | +|---|---| +| CMF | `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`, `cmf.llm_input`, `cmf.llm_output`, `cmf.prompt_pre_invoke`, `cmf.prompt_post_invoke`, `cmf.resource_pre_fetch`, `cmf.resource_post_fetch` | +| HTTP | `http.request`, `http.response` | +| Identity | `identity.resolve` | +| Delegation | `token.delegate` | +| Elicitation | `elicit` | + +An unknown name in `hooks:` fails the load and names the nearest valid hook. The unprefixed names from 0.2.x (`tool_pre_invoke`, `identity_resolve`, `prompt_pre_fetch`, ...) are rejected; the [migration guide]({{< relref "/docs/migration-0.3#hook-names" >}}) lists the renames. + +A Rust host may declare hooks of its own. The config loader refuses a hook name it has no metadata for, so register the metadata (`praxis_policy_core::hooks::register_hook_metadata`) before loading a config that names it. + +## The policy engine + +The `PolicyEngine` owns registration, ordering, capability filtering, timeouts, and error isolation. A plugin can: - **allow** the operation to continue, - **block** it with a violation (surfaced as a deny), or - **modify** the payload, using copy-on-write isolation so one plugin's changes are visible to the next without mutating shared state. -This is the substrate APL effects compile down to. A `deny` is a block; a `redact` is a modify; a `plugin(...)` call is a dispatch. +This is the substrate APL effects compile down to. A `deny` is a block, a `redact` is a modify, and a `run(...)` step is a dispatch. + +A plugin that panics in `sequential` or `transform` mode is contained and reported as a violation with the code `plugin_panic`. A panicking branch of a `parallel:` step is a deny. ## Execution modes @@ -38,12 +54,24 @@ sequential -> transform -> audit -> concurrent -> fire_and_forget | `audit` | serial | no | no | logging, metrics | | `concurrent` | parallel, fail-fast | yes | no | independent gates | | `fire_and_forget` | background, after all phases | no | no | telemetry, async audit | -| `disabled` | not loaded | — | — | plugin off | +| `disabled` | not loaded | n/a | n/a | plugin off | + +Modes and their ordering apply under `dispatch: hooks`. Under the default `dispatch: policy`, a step names the one plugin to run, so nothing orders a hook's entries and a per-plugin `priority:` is a load error. Order the steps instead. Error handling is set separately with `on_error` (`fail`, `ignore`, or `disable`), independent of mode. A `sequential` policy plugin with `on_error: fail` denies the operation if it errors; an `audit` plugin with `on_error: ignore` never blocks the request even if logging fails. ## When to write a plugin -Write a plugin when policy needs an effect the builtins do not provide: a custom validator, a bespoke PDP resolver, an integration with an internal service. Depend on the lean [`cpex-sdk`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-sdk) crate for the `Plugin` and `HookHandler` traits rather than the full runtime. Declare the plugin's capabilities so it receives only the context it needs (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})), register it on a hook, and reference it from APL by its `kind` or name. +Write a plugin when policy needs an effect the builtins do not provide: a custom validator, a bespoke PDP resolver, an integration with an internal service. There is no separate SDK crate. Implement `PluginFactory` against `cpex::prelude`, which carries the `Plugin`, `PluginFactory` and `HookHandler` traits, `TypedHandlerAdapter`, payloads, results and the CMF types. Declare the plugin's capabilities so it receives only the context it needs (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})), register it on a hook, and reference it from APL by name. + +Registration names the `kind:` a policy will write, and takes the factory boxed: + +```rust +engine.register_factory("validator/my-scan", Box::new(MyScanFactory)); +``` + +An unrecognized `kind` fails the config load, so a factory you forgot to register is caught at startup rather than at the first request that needed it. [Tutorial module 9]({{< relref "/docs/tutorial/09-custom-plugin" >}}) builds one end to end. + +Plugins are written in Rust. The Python bindings cannot register a plugin of their own; the Go package can, through a Rust FFI crate it links (see the [Go package README](https://github.com/contextforge-org/cpex/tree/main/go/cpex#writing-plugins-rust-for-go-callers)). Existing Python CPEX plugins run out of process through the [Python plugin host](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-hosts-python). -The bundled plugins (identity, delegation, PII, audit, PDPs) are catalogued in [Builtins]({{< relref "/docs/builtins" >}}); their wiring is in [Configuration]({{< relref "/docs/configuration" >}}). +The bundled plugins and decision points are catalogued in [Builtins]({{< relref "/docs/builtins" >}}); their wiring is in [Configuration]({{< relref "/docs/configuration" >}}). diff --git a/docs/content/docs/quickstart.md b/docs/content/docs/quickstart.md index 3c9fdb9f..ef138233 100644 --- a/docs/content/docs/quickstart.md +++ b/docs/content/docs/quickstart.md @@ -9,35 +9,54 @@ This walks through standing up CPEX as an enforcement point and running the [sce You need Rust 1.96 or newer ([install with rustup](https://rustup.rs)). Section 4 runs the tutorial's first module, which lives in the repo, so clone it first: `git clone https://github.com/contextforge-org/cpex.git && cd cpex`, and run `cargo` commands from that root. +Upgrading from 0.2.x? Read [Migrating to 0.3]({{< relref "/docs/migration-0.3" >}}) first. + ## 1. Add CPEX ```bash -cargo add cpex --features builtins +cargo add cpex ``` -The `builtins` feature compiles in the bundled plugins and PDPs (JWT identity, OAuth delegation, PII scanner, audit logger, Cedar, CEL). For a smaller build, opt into a granular subset: `jwt`, `cedar`, `pii`, and so on (see [Builtins]({{< relref "/docs/builtins" >}})). +`cpex` is a facade over the praxis-policy engine (PPE). It 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. There are no feature flags to pick; see [Builtins]({{< relref "/docs/builtins" >}}). ## 2. Register the runtime -Create a `PluginManager`, register the enabled builtin factories, and install the APL config visitor in one call: +Create a `PolicyEngine`, register the builtin factories and the APL config visitor, and install the HTTP transport the builtins use to reach an IdP: ```rust use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; -let mgr = Arc::new(PluginManager::default()); -cpex::install_builtins(&mgr); +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +cpex::install_default_http_transport(&engine); ``` -After this, the manager knows every builtin `kind` your features enabled, and APL routes can reference them. +After this, the engine knows every builtin `kind`, and APL routes can reference them. The bundled transport refuses private and loopback destinations. To reach a local IdP during development, install `HyperTransport::new().with_allow_private_destinations()` with `engine.set_http_transport(...)` instead; see [Builtins]({{< relref "/docs/builtins#the-http-transport" >}}) for what that switch opens up. ## 3. Write the policy `routes:` is a list, one entry per operation. This route matches the `get_employee` tool, authorizes by role, and redacts on the wire by permission: ```yaml +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] # it fetches the IdP's JWKS + config: + role: user + header: X-User-Token + trusted_issuers: + - issuer: "https://idp.example.com/realms/agents" + audiences: ["cpex-gateway"] + decoding_key: + kind: jwks_url + url: "https://idp.example.com/realms/agents/protocol/openid-connect/certs" + routes: - tool: get_employee + authentication: [jwt-user] args: employee_id: "str" authorization: @@ -50,7 +69,9 @@ routes: employee_id: "str | mask(4)" ``` -The `require(authenticated)` and `require(role.hr)` predicates read attributes resolved from the caller's verified token. How those attributes get populated is covered in [Identity]({{< relref "/docs/apl/identity" >}}); for now, an identity plugin (for example `identity/jwt`) resolves the subject and roles before policy runs. +The `require(authenticated)` and `require(role.hr)` predicates read attributes resolved from the caller's verified token. `authentication: [jwt-user]` names the identity plugin that resolves them; a route that does not list one runs no identity resolution. How those attributes get populated is covered in [Identity]({{< relref "/docs/apl/identity" >}}). + +The host selects the route for each request from the request's entity metadata (`meta.entity_type: tool`, `meta.entity_name: get_employee`). A request without it resolves no route and is denied with `unidentified_request`. ## 4. Run it @@ -60,20 +81,21 @@ The fastest way to see CPEX actually run is the tutorial's first module, a compl cargo run -p cpex-tutorial --example m01_hello ``` -It builds a `PluginManager`, installs the builtins, loads a policy, and dispatches two operations. The setup is the four lines a host writes: +It builds a `PolicyEngine`, installs the builtins and a transport, loads a policy, and dispatches two operations. The setup is the lines a host writes: ```rust -let mgr = Arc::new(PluginManager::default()); -cpex::install_builtins(&mgr); -mgr.load_config_yaml(policy).unwrap(); -mgr.initialize().await.unwrap(); +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +cpex_tutorial::install_http_transport(&engine); // allows a local IdP; not for production +engine.load_config_yaml(policy).unwrap(); +engine.initialize().await.unwrap(); ``` Expected output: ``` ▸ anonymous → get_compensation (route requires authentication) - ✗ DENIED [routes.tool:get_compensation.apl.pre_invocation[0]] access denied + ✗ DENIED [routes.tool:get_compensation.pre_invocation[0]] access denied ▸ anonymous → search_repos (route has no rule) ✓ ALLOWED {"visibility":"public","repositories":[{"name":"brand-site","visibility":"public"}]} @@ -85,11 +107,36 @@ The `get_employee` policy above follows the same model. Once a caller has an ide - An HR caller without `view_ssn` receives the record with `ssn` redacted before it leaves CPEX. - A non-HR caller is denied at `require(role.hr)`; the call never reaches the backend. +## From Python or Go + +The same engine ships as a Python package and a Go module. Both install the builtins, the transport and the secret providers for you. + +```python +from cpex import PolicyEngine + +engine = PolicyEngine("policy.yaml") # allow_private_destinations=True for a local IdP +await engine.initialize() +result = await engine.invoke_hook( + "cmf.tool_pre_invoke", + payload, + {"meta": {"entity_type": "tool", "entity_name": "get_employee"}}, +) +``` + +```go +engine, _ := cpex.NewPolicyEngineDefault() +engine.EnableAPL() // cpex.WithPrivateDestinations() for a local IdP +engine.LoadConfig(yaml) +engine.Initialize() +``` + +See the [Python bindings README](https://github.com/contextforge-org/cpex/tree/main/bindings/python) and the [Go package README](https://github.com/contextforge-org/cpex/tree/main/go/cpex). + ## Next - [Tutorial]({{< relref "/docs/tutorial" >}}): build this up hands-on, one capability per module, with runnable code you can edit and re-run. - [Use Cases]({{< relref "/docs/use-cases" >}}): the full set of controls running end-to-end behind a real gateway. - [APL]({{< relref "/docs/apl" >}}): the full language: predicates, effects, field pipelines, phases. - [Identity]({{< relref "/docs/apl/identity" >}}): resolving callers into the attributes policy reads. -- [PDP Integration]({{< relref "/docs/apl/pdp" >}}): delegating decisions to Cedar, CEL, or an external engine. +- [PDP Integration]({{< relref "/docs/apl/pdp" >}}): delegating decisions to Cedar, CEL, OPA or an external engine. - [Delegation]({{< relref "/docs/apl/delegation" >}}): minting scoped downstream credentials. diff --git a/docs/content/docs/reference.md b/docs/content/docs/reference.md index afccb9cf..284b5dab 100644 --- a/docs/content/docs/reference.md +++ b/docs/content/docs/reference.md @@ -3,30 +3,32 @@ title: "Reference" weight: 120 --- -# Crate Reference +# Reference -CPEX is a Cargo workspace of focused crates. Most hosts depend on `cpex` (the facade); plugin authors depend on `cpex-sdk`. +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. -| Crate | Role | -|-------|------| -| [`cpex`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex) | Host facade. Re-exports the runtime and, with a feature, the builtins. Start here. | -| [`cpex-core`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core) | The runtime: `PluginManager`, executor, hooks, config, extensions. | -| [`cpex-sdk`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-sdk) | Plugin author SDK: the `Plugin` and `HookHandler` traits, payloads, results. Depend on this to write a plugin or PDP resolver. | -| [`cpex-orchestration`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-orchestration) | Async concurrency primitives shared by the runtime. | -| [`cpex-builtins`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-builtins) | Feature-gated bundle of builtin plugins, PDP resolvers, and the session store (see [Builtins]({{< relref "/docs/builtins" >}})). | -| [`cpex-ffi`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-ffi) | C FFI (`cdylib` / `staticlib`) for Go, Python, and WASM host bindings. | -| [`apl-core`](https://github.com/contextforge-org/cpex/tree/main/crates/apl-core) | APL compiler and evaluator: rules, effects, field pipelines, routes. | -| [`apl-cmf`](https://github.com/contextforge-org/cpex/tree/main/crates/apl-cmf) | Bridges typed extensions into the flat attribute bag APL reads. | -| [`apl-cpex`](https://github.com/contextforge-org/cpex/tree/main/crates/apl-cpex) | Runtime adapter: wires APL routes to hooks, dispatches plugins and PDPs. | +| 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) | +| Python plugins | `isolated_venv` kind (`python-host` feature) | [`crates/cpex-hosts-python`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-hosts-python) | -Generated API docs are on [docs.rs/cpex](https://docs.rs/cpex). +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). ## Language bindings -The Rust core is exposed to other languages through `cpex-ffi`. Go bindings live in [`go/cpex`](https://github.com/contextforge-org/cpex/tree/main/go/cpex). Python (PyO3) and WASM bindings are planned over the same core. +The Python package and the Go module wrap the same engine and install the same builtins, HTTP transport and secret providers. Both block private destinations by default and expose a host-side opt-in (`allow_private_destinations=True` in Python, `cpex.WithPrivateDestinations()` in Go). Both expose secret refresh (`refresh_secrets` / `RefreshSecrets`) and a last-success query, which a host using Vault calls on an interval. See [Builtins]({{< relref "/docs/builtins" >}}) and [Configuration]({{< relref "/docs/configuration#resolution-and-refresh" >}}). + +The Go package links `libcpex_ffi` and checks its ABI version at init. The Go module and the FFI artifact must come from the same CPEX release. ## Supply-chain integrity The C FFI is distributed as **signed prebuilt artifacts**. A host that links the FFI rather than building from source verifies the signature on the artifact before use, so the binary boundary between the Rust core and a non-Rust host is not an unverified trust gap. The signing and verification process is documented in [`crates/cpex-ffi/RELEASE.md`](https://github.com/contextforge-org/cpex/blob/main/crates/cpex-ffi/RELEASE.md). (The 0.1.x Python line had its own package-integrity verification for PyPI and Git installs; that mechanism is specific to the Python distribution and is documented in the [0.1.x docs]({{< relref "/docs/0.1.x" >}}).) + +## 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. diff --git a/docs/content/docs/testing.md b/docs/content/docs/testing.md index 3f20c27d..afd2ff7c 100644 --- a/docs/content/docs/testing.md +++ b/docs/content/docs/testing.md @@ -19,26 +19,30 @@ For each route, cover the outcomes its policy produces: ## A table-driven policy test -Load a policy into a manager, drive routes through it with a fake backend, and assert the outcome. The tutorial ships this as a working template you can copy: [`examples/tutorial/tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs). The setup is a small helper: +Load a policy into an engine, drive routes through it with a fake backend, and assert the outcome. The tutorial ships this as a working template you can copy: [`examples/tutorial/tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs). The setup is a small helper: ```rust -async fn manager_with(policy: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(policy).expect("policy should load"); - mgr.initialize().await.expect("initialize"); - mgr +async fn engine_with(policy: &str) -> Arc { + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); // reaches the local IdP; not for production + cpex_tutorial::audit::register(&engine); // the tutorial's own audit plugin + engine.load_config_yaml(policy).expect("policy should load"); + engine.initialize().await.expect("initialize"); + engine } ``` +A policy that fails to load fails the test at `load_config_yaml`, with the same message a deployment would log: an unknown key, a removed hook name, or a plugin no policy reaches. That makes a "every policy loads" test cheap and worth having (the tutorial's `every_module_policy_loads`). + Then a table keeps the allow/deny matrix readable, one row per case: ```rust #[tokio::test] async fn external_email_denied_with_custom_code() { - let mgr = manager_with(POLICY).await; + let engine = engine_with(POLICY).await; let outcome = mediate( - &mgr, + &engine, &Caller::anonymous(), "send_email", json!({ "to": "x@evil.example", "external": true }), @@ -62,13 +66,17 @@ Beyond unit tests, each tutorial module binary supports a `--check` flag that ru ## Integration coverage -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 PII scanner), add an integration test that exercises the real plugin through the manager, 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, a scanner that flags content. Those are the branches policy exists to handle. +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. `praxis_policy_core::http_testing::FakeTransport` (enable the `test-util` feature on `praxis-policy-core = "=0.5.0"` in `[dev-dependencies]`) 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. ## Running ```bash cargo test -p cpex-tutorial # the policy tests above -cargo test --workspace # everything, including the runtime and APL suites +cargo test --workspace # everything: facade, FFI, Python host, tutorial ``` Copy [`examples/tutorial/tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs) as the starting point for tests against your own policy. diff --git a/docs/content/docs/threat-model.md b/docs/content/docs/threat-model.md index f8035114..468878b9 100644 --- a/docs/content/docs/threat-model.md +++ b/docs/content/docs/threat-model.md @@ -35,10 +35,10 @@ Everything to the left of the monitor is assumed hostile, and nothing the policy | Confused deputy / privilege escalation | the agent acts with one blanket identity for all callers | identity resolved per caller from verified tokens; entitlements differ per subject, not per prompt ([Identity]({{< relref "/docs/apl/identity" >}})) | | Cross-request data exfiltration (write-down) | data read in one call leaves through a later, innocent-looking call | `taint(...)` labels the session in CPEX-owned state; later operations deny on the label even with clean payloads ([Session Tainting]({{< relref "/docs/apl/tainting" >}})) | | Credential exposure and over-broad tokens | backends receive the caller's raw IdP credential | `delegate(...)` exchanges it for a fresh audience-scoped token (RFC 8693); the granted scope is verified before use ([Delegation]({{< relref "/docs/apl/delegation" >}})) | -| PII disclosure | sensitive values flow into arguments and out in results | PII scanning on `args`, field-level `redact`/`mask` pipelines on `result` ([Builtins]({{< relref "/docs/builtins" >}})) | +| PII disclosure | sensitive values flow into arguments and out in results | field-level `redact`/`mask` pipelines on `result`, deny rules on `args`, and a host detector plugin where pattern matching is not enough ([Effects]({{< relref "/docs/apl/effects" >}}), [Builtins]({{< relref "/docs/builtins#not-builtins-the-pii-scanner-and-audit-logger" >}})) | | Unauthorized high-impact actions | the model triggers irreversible operations on its own authority | `require_approval(...)` suspends the call for out-of-band human sign-off ([Elicitation]({{< relref "/docs/apl/elicitation" >}})) | | Approval replay | one sign-off is reused for a larger or different action | approvals are scope-bound to the live arguments and validated on resume ([Elicitation]({{< relref "/docs/apl/elicitation" >}})) | -| Unaccountable actions | no trustworthy record of what the agent did | an audit plugin emits an append-only record per decision, including denied attempts ([Patterns]({{< relref "/docs/patterns" >}})) | +| Unaccountable actions | no trustworthy record of what the agent did | a host audit plugin or audit sink records each decision, including denied attempts ([Auditing]({{< relref "/docs/auditing" >}})) | No single row is load-bearing alone. The [defense-in-depth pattern]({{< relref "/docs/patterns#defense-in-depth" >}}) composes them in one route. @@ -74,7 +74,7 @@ CPEX sits beside one agent and mediates its egress. Everything that agent emits **Covers** - The complete outbound surface of the workload, including third-party APIs you do not control and could never gateway. -- Workload identity: the `workload.*` attributes carry attested identity (SPIFFE / mTLS), so policy can bind decisions to which workload is calling, not just which user. +- Workload identity: the `caller_workload.*` and `this_workload.*` attributes carry attested identity (SPIFFE / mTLS), so policy can bind decisions to which workload is calling, not just which user. - Exfiltration control for a specific agent: taint follows the session across every backend the agent reaches. **Does not cover** @@ -104,6 +104,85 @@ CPEX runs inside the agent runtime and mediates operations as the framework issu The placements are not alternatives. The strongest posture pairs an in-framework monitor (rich context, soft and enforcement-tier policy) with a gateway or sidecar at an infrastructure boundary the agent process cannot rewrite (hard-tier policy: identity, information flow, audit). That is the policy spectrum from [Vision]({{< relref "/docs/vision#the-policy-spectrum" >}}): the same APL object at every point, so layering placements costs no policy duplication. +## Builtins that reach outside the process + +Since 0.3.0 the Python and Go builds carry the same builtins as the Rust facade, including four that hold credentials or call out. Each widens what the host process is trusted with. + +### Vault secret provider + +The `vault` provider reads `secrets:` values from Vault KV v2 with Kubernetes or AppRole auth. The engine holds a Vault token and the resolved values in memory for the life of the process. + +- The address must be `https://` unless the document sets `insecure_http: true`. Keep it off outside local development. +- An AppRole `secret_id` comes from an environment variable or a file. A `literal` is refused unless the document also sets `allow_insecure_literal: true`. +- Kubernetes login rereads the projected service-account token on every login, so a rotated token is picked up. +- Refresh reports and their log lines name the secret, the provider and a failure class, never the value. Treat the process's memory and core dumps as holding every declared secret. +- Anyone who can edit the policy document chooses which Vault paths the process reads, within what the Vault role allows. Scope the role to the paths the policy needs. + +### Secret refresh is the host's responsibility + +The engine reads secrets once, at `initialize()`, and never re-reads on its own. A binding host that rotates credentials must call refresh (`refresh_secrets`, `RefreshSecrets`, `cpex_refresh_secrets`) on an interval and alarm when a provider's last success grows old. A failed re-read keeps the last good value, so a Vault outage degrades silently to a stale credential. The refresh interval is the upper bound on how long a revoked credential stays in use. A host that never calls refresh never rotates. + +### CIBA approval channel + +`elicitation/ciba` trusts the approver identity in the token the OP returns, because it arrives over a client-authenticated TLS poll; it does not verify that token's signature itself. Reach the CIBA and token endpoints over `https://` with real client credentials. See [Elicitation]({{< relref "/docs/apl/elicitation#a-note-on-genuineness-and-args-binding" >}}). + +### API-key directory + +`identity/api-key` resolves opaque keys against a file of SHA-256 digests or an HTTP directory. + +- The file stores digests, not keys. Use high-entropy keys so a leaked file does not yield to offline guessing. +- A revoked key can still authenticate until the next successful file reload (`refresh_secs`) or until its HTTP cache entry expires (`cache.ttl_secs`). If a reload fails, old records stay in use up to `max_staleness_secs`, then lookups deny. +- An HTTP directory is an outbound call: it needs `perform_http` and goes through the host transport. +- The presented key is not kept in the raw credentials, so it is never forwarded downstream. + +### OPA decision point + +`opa` evaluates Rego in process (regorus); it makes no network call. Its inputs are the attribute bag plus structured `args` and `llm.request`, which come from the client. The sensitive headers `Authorization`, `Cookie` and `X-API-Key` are stripped before any PDP sees the request. PDP-generated errors name types, not values, but text a policy author writes (an OPA `reason`, an APL `deny('...')`) reaches the client verbatim: do not copy payload values into it. + +## Outbound HTTP and private destinations + +Every outbound builtin (JWKS fetch, token exchange, CIBA, API-key HTTP directory, Vault) uses one transport the host installs. The bundled transport refuses loopback, RFC 1918, link-local (including cloud metadata) and CGNAT addresses at the address it would dial, including hostnames that resolve only to such addresses. This blocks the common SSRF path where a policy author, or a token's issuer field, points a plugin at an internal service. + +Each surface has a host-side opt-in that lifts the block (`with_allow_private_destinations()`, `allow_private_destinations=True`, `WithPrivateDestinations()`, `CPEX_APL_ALLOW_PRIVATE_DESTINATIONS`). It is engine-wide: once set, every outbound builtin may reach every private address, including `169.254.169.254`. No policy document can set it. The tutorial and demos set it to reach a local IdP; production hosts should not. To reach an in-cluster IdP or Vault, install a host transport with explicit egress rules. + +A refused call fails closed: `delegation.egress_denied`, `elicitation.egress_denied`, or `auth.jwks_unavailable` for JWT, which an operator may read as an IdP outage. + +## The Python plugin host + +The `isolated_venv` host runs each Python plugin in its own subprocess and virtualenv, so a plugin crash or a dependency conflict stays out of the engine's process. It does not sandbox the plugin: the subprocess runs with the host's user and network access. + +For a Python worker that does not claim CMF support, the host translates each `cmf.*` hook into the legacy hook and payload the worker knows. Where it cannot represent a request faithfully (an LLM hook, a message with zero or several matching parts, a binary resource, a modified payload the message cannot hold), it fails closed with `legacy_shim_unsupported` rather than allowing or skipping the plugin. On a post hook it also refuses a message with any other part that carries content, since a filter that cannot see that part would pass it through unredacted. On a pre hook such parts pass through unchanged: the plugin authorizes the call, request or URI it sees. + +Raw tokens reach a Python plugin only through a capability-gated `credential` object. On `identity.resolve` the payload a plugin sees omits the credential-bearing request headers (the source header, `authorization`, `proxy-authorization`, cookies, `x-api-key`, and any header holding the token) unless the plugin declares `read_inbound_credentials`. A delegator gets the caller's bearer token under the same capability. Once a token is in the worker, the gate no longer constrains it: the plugin's code and dependencies can read it. + +## Identity input from binding hosts + +The Python, Go and C bindings take an `identity.resolve` input from the host. That input may set only `source`, `source_header`, `headers`, `client_host` and `client_port`. A host bug or an attacker-shaped request that pre-filled `subject`, `client`, `caller_workload`, `delegation`, `raw_credentials`, `resolved_at` or `raw_claims` could forge the identity the resolvers return, so the bindings refuse it before any plugin runs. The result does not echo the request headers, so the inbound token does not travel back into host logs or responses. + +A hook name removed in 0.3 is refused at invoke time as well as at load. It would reach no handler and come back allowed with no policy evaluated. + +Extensions are a different matter: the engine trusts the `security.subject` a host passes on a later call. Pass only a subject that came from `identity.resolve`, or use Go `InvokeResolved` to keep resolution inside the engine. + +## Known limitations + +### 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. + +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. + +### Audit sinks cannot run beside routes + +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" >}}). + +### Host-supplied client identity is visible only through `read_client` + +A host may pass `security.client` in the extensions it sends, but route rules see `client.*` only when a plugin the route references declares `read_client`. Otherwise the attributes read as absent. Rules written as `require(...)` then deny, which is safe; a rule that allows on the absence of a client attribute would not be. Resolve the client with an identity plugin on the route, and write client checks as `require(...)`. + +### HTTP routes are Rust-only + +`http:` routes and the `http.request` / `http.response` hooks work from a Rust host only. The Python and Go bindings cannot build the HTTP payload, so a binding host cannot enforce generic HTTP policy. Put that enforcement in a Rust host or gateway. + ## Out of scope CPEX is a reference monitor, not a model alignment technique. Explicitly outside this threat model: diff --git a/docs/content/docs/tutorial/01-hello.md b/docs/content/docs/tutorial/01-hello.md index 68be97a2..ef43f04b 100644 --- a/docs/content/docs/tutorial/01-hello.md +++ b/docs/content/docs/tutorial/01-hello.md @@ -15,26 +15,46 @@ You have tools an agent can call. Some should be gated, some open. You do not wa ## Build it -The host is three lines plus loading a policy. From [`examples/tutorial/examples/m01_hello.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m01_hello.rs): +The host builds an engine, registers the builtins, installs an HTTP transport, and loads a policy. From [`examples/tutorial/examples/m01_hello.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m01_hello.rs): ```rust -let mgr = Arc::new(PluginManager::default()); -cpex::install_builtins(&mgr); // register the bundled plugins + APL visitor -mgr.load_config_yaml(POLICY).unwrap(); // load policies/m01.yaml -mgr.initialize().await.unwrap(); +// --- Set up the enforcement point (the same steps a real host writes) --- +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +// The HTTP transport builtins use to reach an IdP. Module 1 calls no IdP, +// but every module installs it the same way; see `install_http_transport`. +cpex_tutorial::install_http_transport(&engine); +engine + .load_config_yaml(POLICY) + .expect("policy m01.yaml should load"); +engine.initialize().await.expect("initialize"); ``` -The policy ([`policies/m01.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m01.yaml)) defines two routes: +`install_builtins` registers the bundled plugins and the APL config visitor. `install_http_transport` sets the HTTP client that builtins use to fetch signing keys, exchange tokens, and open approval requests. Builtins do not bring their own: the host installs one on the engine. From [`src/lib.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/src/lib.rs): + +```rust +pub fn install_http_transport(engine: &Arc) { + engine.set_http_transport(Arc::new( + HyperTransport::new().with_allow_private_destinations(), + )); +} +``` + +The bundled transport refuses private and loopback destinations. The tutorial IdP runs on `localhost`, so this one allows them. **This is not a production setting:** it lets a policy document point a plugin at anything on the host's network. A production host keeps the default (`cpex::install_default_http_transport`) or installs its own transport with its own egress rules. Module 1 makes no outbound call; the transport matters from module 2 on. + +The policy ([`policies/m01.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m01.yaml)) defines two routes. `get_compensation` requires an authenticated caller, so it denies the anonymous one. `search_repos` has a single unconditional `allow`. An `authorization:` block must name at least one step, so an open route says so explicitly. ```yaml routes: - tool: get_compensation authorization: pre_invocation: - - "require(authenticated)" # denies an anonymous caller + - "require(authenticated)" + - tool: search_repos authorization: - pre_invocation: [] # no rule, so open + pre_invocation: + - "allow" ``` Each call goes through `mediate()`, the harness wrapper around the loop a host owns: resolve identity, run policy, call the backend, run policy on the result. It is harness code, not a CPEX API. Module 9 opens it up. @@ -47,7 +67,7 @@ cargo run -p cpex-tutorial --example m01_hello ``` ▸ anonymous → get_compensation (route requires authentication) - ✗ DENIED [routes.tool:get_compensation.apl.pre_invocation[0]] access denied + ✗ DENIED [routes.tool:get_compensation.pre_invocation[0]] access denied ▸ anonymous → search_repos (route has no rule) ✓ ALLOWED {"visibility":"public","repositories":[{"name":"brand-site","visibility":"public"}]} @@ -60,8 +80,8 @@ Same anonymous caller, same host code. The route decided the outcome, and the de ## Try it 1. Change the failing predicate. In `examples/tutorial/policies/m01.yaml`, change `require(authenticated)` to `require(role.hr)` and re-run. Expect: `get_compensation` still denies, because the anonymous caller has no role either. The displayed reason is the same generic, position-based code (`...pre_invocation[0]] access denied`); what changed is which predicate rejected the call, not the visible text. -2. Open the gated route. Delete the `require(authenticated)` line (leave `pre_invocation: []`) and re-run. Expect: both calls allow. -3. Gate the open route. `get_compensation` already denies; add `- "require(authenticated)"` under `search_repos` too, leaving `get_compensation` unchanged, and re-run. Expect: both calls now deny. +2. Open the gated route. Replace `require(authenticated)` under `get_compensation` with `allow` and re-run. Expect: both calls allow. +3. Gate the open route. `get_compensation` already denies; replace `allow` under `search_repos` with `require(authenticated)`, leaving `get_compensation` unchanged, and re-run. Expect: both calls now deny. Reset any time with `git checkout -- examples/tutorial/policies`. @@ -72,7 +92,7 @@ The route's `require(authenticated)` predicate failed, because the caller is ano {{< /details >}} {{< details "What makes search_repos allow?" >}} -It has no denying rule. A route with an empty or absent `pre_invocation` is open by default. CPEX blocks only what policy tells it to. +Its only step is `allow`, which never denies. CPEX blocks only what policy tells it to. An empty `pre_invocation` list is a load error, so a route that should be open says `allow`. {{< /details >}} ## Go deeper diff --git a/docs/content/docs/tutorial/02-identity.md b/docs/content/docs/tutorial/02-identity.md index 1f902206..1f5e5f2d 100644 --- a/docs/content/docs/tutorial/02-identity.md +++ b/docs/content/docs/tutorial/02-identity.md @@ -23,7 +23,12 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + # Fetching the JWKS is an outbound HTTP call, made through the transport + # the host installs. A plugin may use it only if it declares perform_http. + capabilities: [perform_http] config: + role: user + header: Authorization claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial @@ -32,7 +37,8 @@ plugins: decoding_key: kind: jwks_url url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs - insecure_http: true # localhost speaks http; never in production + insecure_http: true # localhost speaks http; never set this in production + refresh_secs: 600 leeway_seconds: 60 routes: @@ -47,7 +53,9 @@ routes: The plugin validates the JWT offline against the realm's signing keys, fetched once from the JWKS url and cached. It checks issuer, audience, expiry, and signature. A token that fails any check is rejected before any authorization rule runs. The realm emits flat `roles` and `permissions` claims (see [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). The `standard` mapper turns `roles: ["hr"]` into `role.hr = true` and `permissions: ["view_ssn"]` into `perm.view_ssn = true`. -The snippet is abbreviated; the file also sets `role`, `header`, and the JWKS `refresh_secs`. The tutorial personas: alice (hr and `view_ssn`), dana (hr, no `view_ssn`), evan (engineer), sam (security). +Fetching the JWKS is an outbound HTTP call. It goes through the transport the host installed in module 1, and a plugin may use that transport only if it declares the `perform_http` capability. Leave out `capabilities: [perform_http]` and the plugin fails at initialize or on the first request, not at load. If the transport refuses the destination (the bundled default refuses `localhost`), the denial shows as `auth.jwks_unavailable`. The same code appears while Keycloak is still booting. + +The tutorial personas: alice (hr and `view_ssn`), dana (hr, no `view_ssn`), evan (engineer), sam (security). ## Run it @@ -60,7 +68,7 @@ cargo run -p cpex-tutorial --example m02_identity ✓ ALLOWED { ... } ▸ evan (engineer) → get_compensation (fails require(role.hr)) - ✗ DENIED [...] access denied + ✗ DENIED [routes.tool:get_compensation.pre_invocation[1]] access denied ▸ garbage token → get_compensation (rejected at validation) ✗ DENIED [auth.malformed_header] ... diff --git a/docs/content/docs/tutorial/03-shaping.md b/docs/content/docs/tutorial/03-shaping.md index f9f1a71a..639b02e3 100644 --- a/docs/content/docs/tutorial/03-shaping.md +++ b/docs/content/docs/tutorial/03-shaping.md @@ -21,14 +21,15 @@ The route allows the call, then a `result:` field pipeline transforms the respon routes: - tool: get_compensation authorization: - pre_invocation: [] + pre_invocation: + - "allow" result: ssn: "str | redact(!perm.view_ssn)" salary: "int | redact(!role.hr)" employee_id: "str | mask(4)" ``` -Each entry reads `: " | ()"`. The op runs only when its predicate holds. `redact(!perm.view_ssn)` means redact when the caller does not have `view_ssn`. `mask(4)` always keeps the last four characters. This runs in the Post phase, after the backend returns and before the caller sees the response. +Each entry reads `: " | ()"`. The op runs only when its predicate holds. `redact(!perm.view_ssn)` means redact when the caller does not have `view_ssn`. `mask(4)` always keeps the last four characters. The `allow` step is there because an `authorization:` block must name at least one step. This runs in the Post phase, after the backend returns and before the caller sees the response. ## Run it diff --git a/docs/content/docs/tutorial/04-effects.md b/docs/content/docs/tutorial/04-effects.md index 1d031ebc..ea10723a 100644 --- a/docs/content/docs/tutorial/04-effects.md +++ b/docs/content/docs/tutorial/04-effects.md @@ -20,10 +20,9 @@ Real routes do more than one thing. They audit the attempt, apply a business rul ```yaml plugins: - name: audit-log - kind: audit/logger + kind: tutorial/audit-line hooks: [cmf.tool_pre_invoke] capabilities: [read_subject, read_meta] - config: { destination: stderr } routes: - tool: send_email @@ -34,6 +33,21 @@ routes: - "require(authenticated)" ``` +CPEX ships no audit plugin of its own, because the record's format and destination are what each deployment replaces. The `audit-log` plugin here has `kind: tutorial/audit-line`, a small host plugin in [`src/audit.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/src/audit.rs). It writes one JSON line per call to stderr with the entity, the subject, the tool name, and the argument keys. It leaves argument values out on purpose, since they can carry PII or secrets. The host registers its factory before loading the policy. From [`examples/m04_effects.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m04_effects.rs): + +```rust +let engine = Arc::new(PolicyEngine::default()); +cpex::install_builtins(&engine); +cpex_tutorial::install_http_transport(&engine); +cpex_tutorial::audit::register(&engine); +engine + .load_config_yaml(POLICY) + .expect("policy m04.yaml should load"); +engine.initialize().await.expect("initialize"); +``` + +Policy reaches it by name with `run(audit-log)`, like any other plugin. Module 9 shows how to build one. + Three effects, top to bottom: 1. `run(audit-log)` records the attempt and always allows, so the pipeline continues. This is a side effect. @@ -48,15 +62,15 @@ cargo run -p cpex-tutorial --example m04_effects ``` ▸ send_email to an external recipient (custom deny guard halts) -{"ts":"...","plugin":"audit-log","entity":{"type":"tool","name":"send_email"}, ...} +{"ts":1791421465,"plugin":"audit-log","entity":{"type":"tool","name":"send_email"},"tool_call":{"name":"send_email","arg_keys":["external","subject","to"]}} ✗ DENIED [email.external_blocked] outbound email to external recipients is blocked ▸ send_email to an internal recipient (guard passes, auth check halts) -{"ts":"...","plugin":"audit-log", ...} - ✗ DENIED [routes.tool:send_email.apl.pre_invocation[2]] access denied +{"ts":1791421465,"plugin":"audit-log","entity":{"type":"tool","name":"send_email"},"tool_call":{"name":"send_email","arg_keys":["external","subject","to"]}} + ✗ DENIED [routes.tool:send_email.pre_invocation[2]] access denied ``` -The audit line (stderr) fires on both calls, because it runs before the denials. The external call halts at the custom guard with `email.external_blocked`. The internal call gets past the guard to `require(authenticated)` and halts there. Same route, different stopping point, because the order is policy. +The caller is anonymous, so the audit line has no `subject`. The `ts` value is a Unix timestamp and differs on your run. The audit line (stderr) fires on both calls, because it runs before the denials. The external call halts at the custom guard with `email.external_blocked`. The internal call gets past the guard to `require(authenticated)` and halts there. Same route, different stopping point, because the order is policy. ## Try it @@ -77,7 +91,8 @@ For a hand-written guard, the second argument to `deny('reason', 'code')`. For a ## Go deeper - [Effects & Sequencing]({{< relref "/docs/apl/effects" >}}) for the full effect set, halt-on-deny, and `on_allow`/`on_deny` reactions. -- [Builtins]({{< relref "/docs/builtins" >}}) for the audit logger and other bundled plugins. +- [Builtins]({{< relref "/docs/builtins" >}}) for the bundled plugins. +- [Module 9]({{< relref "09-custom-plugin" >}}) for how a host plugin like `tutorial/audit-line` is built. ## Next diff --git a/docs/content/docs/tutorial/05-pdp.md b/docs/content/docs/tutorial/05-pdp.md index 6046ac19..418cd531 100644 --- a/docs/content/docs/tutorial/05-pdp.md +++ b/docs/content/docs/tutorial/05-pdp.md @@ -19,9 +19,8 @@ Declare a PDP once, then reference it from a step. From [`policies/m05.yaml`](ht ```yaml global: - apl: - pdp: - - kind: cel + pdp: + - kind: cel routes: - tool: search_repos @@ -36,6 +35,8 @@ routes: - "deny('engineers read internal only; security reads any', 'repo.policy_denied')" ``` +The file also declares the same `keycloak` identity plugin as module 2 (with `capabilities: [perform_http]`); it is left out above. + The `cel:` step evaluates a boolean expression over the same attributes predicates see: roles and request arguments. `true` allows, `false` runs `on_deny`. Guard optional attributes with `has(...)`, because referencing an unset attribute is an error, and CEL errors fail closed. Cedar is available too (`kind: cedar-direct`) when you want policy as data rather than an expression. ## Run it diff --git a/docs/content/docs/tutorial/06-delegation.md b/docs/content/docs/tutorial/06-delegation.md index b6163252..48fb2371 100644 --- a/docs/content/docs/tutorial/06-delegation.md +++ b/docs/content/docs/tutorial/06-delegation.md @@ -24,21 +24,40 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] - config: { ... as in module 2 ... } + capabilities: [perform_http] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + # The delegator needs to read the caller's inbound token (to exchange + # it) and write the minted downstream token. Declaring these here lets + # the route surface them to the enforcement boundary; without them the + # inbound token is filtered out before the exchange and it fails. + # perform_http lets it call the token endpoint. + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway - client_secret_source: { kind: literal, secret: gateway-dev-secret } - insecure_http: true + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true # localhost speaks http; never in production routes: - tool: get_compensation - authentication: [keycloak] + authentication: + - keycloak authorization: pre_invocation: - "require(role.hr)" @@ -50,7 +69,8 @@ The `delegate(...)` step exchanges the caller's token for one scoped to the `wor Two things are load-bearing: -- The plugin declares `capabilities: [read_inbound_credentials, write_delegated_tokens]`. Without them, the caller's inbound token is filtered out before the exchange runs, and delegation fails with an empty token. Capabilities scope what each step may touch. +- The plugin declares `read_inbound_credentials` and `write_delegated_tokens`. Without them, the caller's inbound token is filtered out before the exchange runs, and delegation fails with an empty token. Capabilities scope what each step may touch. +- The plugin also declares `perform_http`. The exchange is an outbound call to the token endpoint, made through the transport the host installed (module 1), and only plugins that declare `perform_http` may use it. Both plugins here declare it: `keycloak` to fetch the JWKS, `workday-oauth` to call the token endpoint. - The gateway client in Keycloak must be allowed to exchange for the target audience. In the tutorial realm, `cpex-gateway` sets `standard.token.exchange.audiences: workday-api,github-api` and carries audience mappers for those clients (see [`idp/realm-export.json`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). ## Run it @@ -64,14 +84,14 @@ cargo run -p cpex-tutorial --example m06_delegation ✓ ALLOWED { ... } ▸ evan (engineer) → get_compensation (denied at require(role.hr), no delegation) - ✗ DENIED [...] access denied + ✗ DENIED [routes.tool:get_compensation.pre_invocation[0]] access denied ``` alice's call runs a real token exchange and gets a `workday-api`-scoped token before the backend call. evan never reaches delegation, because `require(role.hr)` stops him first. Delegation is cheap to skip when it is not needed. ## Try it -1. Drop the capabilities. Remove the `capabilities:` line from the plugin and re-run. Expect: alice is denied with `delegation.bad_request` (empty token), because the inbound credential was filtered out. +1. Drop the credential capabilities. Remove `read_inbound_credentials` and `write_delegated_tokens` from the `workday-oauth` plugin's `capabilities:` list (keep `perform_http`) and re-run. Expect: alice is denied with `delegation.bad_request` (empty token), because the inbound credential was filtered out. 2. Wrong audience. Change `audience:` to a client the gateway may not target and re-run. Expect: the exchange is rejected by Keycloak and the step denies. 3. Narrow the grant (advanced). In `policies/m06.yaml`, add a `permissions:` argument to the `delegate(...)` step so it reads `delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])`. The exchange then requests only that scope. For Keycloak to actually issue it, the `read_compensation` client scope must exist on the realm and be assigned to the `workday-api` client (see [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)); without that realm setup the exchange returns the default scope. This is how you narrow the downstream grant to exactly what the operation needs. diff --git a/docs/content/docs/tutorial/07-tainting.md b/docs/content/docs/tutorial/07-tainting.md index 993158e4..a55fc295 100644 --- a/docs/content/docs/tutorial/07-tainting.md +++ b/docs/content/docs/tutorial/07-tainting.md @@ -15,19 +15,21 @@ Most authorization systems judge each request alone. That misses a whole class o ## Build it -One route taints the session; another refuses when the taint is present. From [`policies/m07.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m07.yaml): +One route taints the session; another refuses when the taint is present. The file also declares the `keycloak` plugin from module 2 (left out here). From [`policies/m07.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m07.yaml): ```yaml routes: - tool: get_compensation - authentication: [keycloak] + authentication: + - keycloak authorization: pre_invocation: - "require(role.hr)" - - "taint(secret, session)" # mark the session as having read secret data + - "taint(secret, session)" - tool: send_email - authentication: [keycloak] + authentication: + - keycloak authorization: pre_invocation: - "require(authenticated)" @@ -63,16 +65,14 @@ The same `send_email` call is allowed in a clean session and denied in the sessi 2. Rename the label. In `examples/tutorial/policies/m07.yaml`, change `taint(secret, session)` to `taint(pii, session)` and update the guard `security.labels contains "secret"` to `"pii"`. Re-run. Expect: the write-down still blocks, now keyed on the `pii` label. Labels are arbitrary names you choose; the write and the read just have to agree. 3. Persist across restarts. The default store is in-memory and resets when the program exits. To back it with Valkey: - Start Valkey: `docker compose -f examples/tutorial/idp/docker-compose.yml --profile valkey up -d`. - - Enable the feature: in `examples/tutorial/Cargo.toml`, change the `cpex` dependency to `features = ["builtins", "valkey"]`. - In `policies/m07.yaml`, add a session store under `global`: ```yaml global: - apl: - session_store: - kind: valkey - endpoint: localhost:6379 + session_store: + kind: valkey + endpoint: localhost:6379 ``` - Re-run: the taint now lives in Valkey and survives a process restart. + The Valkey store is a builtin, so no Cargo feature is needed. Re-run: the taint now lives in Valkey and survives a process restart. ## Checkpoint diff --git a/docs/content/docs/tutorial/08-elicitation.md b/docs/content/docs/tutorial/08-elicitation.md index 68040b7c..3b33e967 100644 --- a/docs/content/docs/tutorial/08-elicitation.md +++ b/docs/content/docs/tutorial/08-elicitation.md @@ -22,14 +22,27 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] - config: { ... as in module 2 ... } + capabilities: [perform_http] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + - name: manager-approval kind: approval-channel hooks: [elicit] routes: - tool: send_email - authentication: [keycloak] + authentication: + - keycloak authorization: pre_invocation: - "require(authenticated)" @@ -41,13 +54,68 @@ The first time a caller hits this route, the approval is pending, so policy susp The approval plugin ([`examples/m08_elicitation.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m08_elicitation.rs)) implements the `elicit` hook's three operations against a tiny approval channel: ```rust -match payload.operation() { - ElicitationOp::Dispatch => { /* open a pending request, return its id */ } - ElicitationOp::Check => { /* report Pending / Resolved{Approved|Denied} */ } - ElicitationOp::Validate => { /* confirm the approver */ } +impl HookHandler for ChannelApprover { + async fn handle( + &self, + payload: &ElicitationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let mut out = payload.clone(); + match payload.operation() { + ElicitationOp::Dispatch => { + let approver = payload.from().to_string(); + let id = elicitation_id_for(&approver); + self.channel + .open(&id, &approver, payload.purpose().unwrap_or("(no purpose)")); + out.id = Some(id); + out.status = Some(ElicitationStatusKind::Pending); + out.approver = Some(approver); + }, + ElicitationOp::Check => { + let id = payload.elicitation_id().unwrap_or_default(); + match self.channel.status(id) { + Some(Status::Approved) => { + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(ElicitationOutcomeKind::Approved); + }, + Some(Status::Denied) => { + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(ElicitationOutcomeKind::Denied); + }, + _ => out.status = Some(ElicitationStatusKind::Pending), + } + }, + ElicitationOp::Validate => { + out.valid = Some(true); + out.approver = Some(payload.from().to_string()); + }, + } + PluginResult::modify_payload(out) + } } ``` +The plugin traits come from `cpex::prelude`, and the elicitation types (`ElicitationHook`, `ElicitationOp`, `ElicitationPayload`) from `praxis_policy_core::elicitation`. + +`approval-channel` is not a builtin. The host registers its factory on the engine before loading the policy: + +```rust +let engine = Arc::new(PolicyEngine::default()); +engine.register_factory( + "approval-channel", + Box::new(ChannelApproverFactory { + channel: channel.clone(), + }), +); +cpex::install_builtins(&engine); +cpex_tutorial::install_http_transport(&engine); +engine + .load_config_yaml(POLICY) + .expect("policy m08.yaml should load"); +engine.initialize().await.expect("initialize"); +``` + The channel is served over HTTP so a human can approve with `curl`. In production this would be an OIDC CIBA backchannel or a push to the approver's phone. CIBA (Client-Initiated Backchannel Authentication, [OpenID spec](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)) is the OpenID flow where the app asks the identity provider to prompt a user on a separate device and polls for their decision; see [Human-in-the-Loop Elicitation]({{< relref "/docs/apl/elicitation" >}}) for how CPEX drives it. The point here is CPEX's suspend and resume model, not the notification transport. ## Run it diff --git a/docs/content/docs/tutorial/09-custom-plugin.md b/docs/content/docs/tutorial/09-custom-plugin.md index 4bb32b63..c6400874 100644 --- a/docs/content/docs/tutorial/09-custom-plugin.md +++ b/docs/content/docs/tutorial/09-custom-plugin.md @@ -19,27 +19,60 @@ A plugin is a Rust type implementing a hook handler. This one, a business-hours ```rust impl HookHandler for BusinessHours { - async fn handle(&self, payload: &MessagePayload, _ext: &Extensions, _ctx: &mut PluginContext) - -> PluginResult - { - let hour = payload.message.get_tool_calls().into_iter().next() - .and_then(|tc| tc.arguments.get("hour")).and_then(|v| v.as_u64()); + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Read the `hour` argument off the tool call. + let hour = payload + .message + .get_tool_calls() + .into_iter() + .next() + .and_then(|tc| tc.arguments.get("hour")) + .and_then(|v| v.as_u64()); + match hour { Some(h) if h >= self.open_hour && h < self.close_hour => PluginResult::allow(), - Some(h) => PluginResult::deny(PluginViolation::new("office.closed", - format!("requested at hour {h}, outside {}-{}", self.open_hour, self.close_hour))), - None => PluginResult::deny(PluginViolation::new("office.no_hour", "no `hour` argument")), + Some(h) => PluginResult::deny(PluginViolation::new( + "office.closed", + format!( + "requested at hour {h}, outside business hours {}-{}", + self.open_hour, self.close_hour + ), + )), + None => PluginResult::deny(PluginViolation::new( + "office.no_hour", + "request did not carry an `hour` argument", + )), } } } ``` -A factory builds it from config and wires the handler onto a hook. You register the factory before loading the policy, then reference the plugin by name. The traits come from `cpex-sdk`; the factory and handler-adapter plumbing come from `cpex-core`, the same pattern every builtin uses. +A factory builds it from config and wires the handler onto a hook. You register the factory before loading the policy, then reference the plugin by name. The traits, the factory, and the handler-adapter plumbing all come from `cpex::prelude`, the same pattern every builtin uses. The `tutorial/audit-line` plugin from module 4 ([`src/audit.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/src/audit.rs)) is built the same way. ```rust -mgr.register_factory("business-hours", Box::new(BusinessHoursFactory)); -cpex::install_builtins(&mgr); -mgr.load_config_yaml(POLICY).unwrap(); +use cpex::prelude::{ + CmfHook, Extensions, HookHandler, MessagePayload, Plugin, PluginConfig, PluginContext, + PluginError, PluginFactory, PluginInstance, PluginResult, PluginViolation, TypedHandlerAdapter, +}; +use cpex::PolicyEngine; +``` + +```rust +let engine = Arc::new(PolicyEngine::default()); +// Register the custom factory BEFORE loading config, so the APL visitor +// can resolve `kind: business-hours` to it. +engine.register_factory("business-hours", Box::new(BusinessHoursFactory)); +cpex::install_builtins(&engine); +cpex_tutorial::install_http_transport(&engine); +engine + .load_config_yaml(POLICY) + .expect("policy m09.yaml should load"); +engine.initialize().await.expect("initialize"); ``` The policy references it like any builtin ([`policies/m09.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m09.yaml)): @@ -49,7 +82,10 @@ plugins: - name: business-hours kind: business-hours hooks: [cmf.tool_pre_invoke] - config: { open_hour: 9, close_hour: 17 } + config: + open_hour: 9 + close_hour: 17 + routes: - tool: get_compensation authorization: @@ -65,7 +101,7 @@ cargo run -p cpex-tutorial --example m09_custom_plugin ``` ▸ get_compensation at 10:00 (within 9-17 window) - ✓ ALLOWED { ... } + ✓ ALLOWED {"employee_id":"e-1001","name":"Alice Okafor","title":"Staff Engineer","salary":198000,"ssn":"521-38-7710"} ▸ get_compensation at 22:00 (outside the window) ✗ DENIED [office.closed] requested at hour 22, outside business hours 9-17 @@ -82,7 +118,7 @@ cargo run -p cpex-tutorial --example m09_custom_plugin pre_invocation: - "run(business-hours)" ``` - Then add a call to it in `m09_custom_plugin.rs` (e.g. `mediate(&mgr, &caller, "search_repos", json!({ "visibility": "public", "hour": 22 }), backends::search_repos)`), re-run, and confirm `search_repos` is denied at hour 22 too. One plugin, many routes. + Then add a call to it in `m09_custom_plugin.rs` (e.g. `mediate(&engine, &caller, "search_repos", json!({ "visibility": "public", "hour": 22 }), backends::search_repos)`), re-run, and confirm `search_repos` is denied at hour 22 too. One plugin, many routes. ## Checkpoint diff --git a/docs/content/docs/tutorial/10-testing.md b/docs/content/docs/tutorial/10-testing.md index 43c61679..d0869361 100644 --- a/docs/content/docs/tutorial/10-testing.md +++ b/docs/content/docs/tutorial/10-testing.md @@ -15,27 +15,49 @@ Policy decides who sees what. A careless edit can silently open a route or over- ## Build it -A test loads a policy into a manager and calls routes through `mediate()` with a fake backend, then asserts. Table-driven cases keep the matrix readable. From [`tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs): +A test loads a policy into an engine and calls routes through `mediate()` with a fake backend, then asserts. Table-driven cases keep the matrix readable. From [`tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs): ```rust -async fn manager_with(policy: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(policy).expect("policy should load"); - mgr.initialize().await.expect("initialize"); - mgr +/// Load a policy string into a ready-to-use engine. +async fn engine_with(policy: &str) -> Arc { + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + cpex_tutorial::audit::register(&engine); + engine.load_config_yaml(policy).expect("policy should load"); + engine.initialize().await.expect("initialize"); + engine } +``` + +The setup is the same as a module binary: builtins, the tutorial HTTP transport, and the tutorial's `tutorial/audit-line` plugin that `m04.yaml` declares. Each case is one row: +```rust #[tokio::test] async fn module4_external_email_denied_with_custom_code() { - let mgr = manager_with(M04).await; - let outcome = mediate(&mgr, &Caller::anonymous(), "send_email", - json!({ "to": "x@evil.example", "external": true }), - |a| backends::dispatch("send_email", a)).await; - assert!(matches!(outcome, Outcome::Denied { code, .. } if code == "email.external_blocked")); + let engine = engine_with(M04).await; + let cases = [ + Case { + tool: "send_email", + args: json!({ "to": "x@evil.example", "subject": "hi", "external": true }), + want_allowed: false, + want_code: Some("email.external_blocked"), + }, + Case { + tool: "send_email", + args: json!({ "to": "x@corp.example", "subject": "hi", "external": false }), + want_allowed: false, // anonymous, so require(authenticated) halts it + want_code: None, + }, + ]; + for c in &cases { + run_case(&engine, c).await; + } } ``` +A third test, `every_module_policy_loads`, loads every module's policy without calling `initialize()`. Loading is where the engine checks a policy's shape: unknown keys, plugins no step reaches, steps naming undeclared plugins, and capabilities a plugin needs but does not declare. Skipping `initialize()` keeps it offline, since that is where identity plugins fetch their JWKS. + Anonymous callers exercise structural rules (authentication gates, argument guards, result pipelines) with no Keycloak. For identity-dependent rules, mint tokens the way the module binaries do. ## Run it @@ -45,9 +67,10 @@ cargo test -p cpex-tutorial ``` ``` -running 2 tests +running 3 tests test module1_gates_by_authentication ... ok test module4_external_email_denied_with_custom_code ... ok +test every_module_policy_loads ... ok ``` The example binary runs the same idea in the tutorial's output format: @@ -56,14 +79,31 @@ The example binary runs the same idea in the tutorial's output format: cargo run -p cpex-tutorial --example m10_testing ``` +``` +▸ send_email {"to":"x@evil.example","external":true} → expect DENY +{"ts":1791421466,"plugin":"audit-log","entity":{"type":"tool","name":"send_email"},"tool_call":{"name":"send_email","arg_keys":["external","to"]}} + ✗ DENIED [email.external_blocked] outbound email to external recipients is blocked + +▸ send_email {"to":"x@corp.example","external":false} → expect DENY +{"ts":1791421466,"plugin":"audit-log","entity":{"type":"tool","name":"send_email"},"tool_call":{"name":"send_email","arg_keys":["external","to"]}} + ✗ DENIED [routes.tool:send_email.pre_invocation[2]] access denied +``` + +The JSON lines are the audit plugin writing to stderr. They list argument keys, never values. + ## Try it 1. Break a policy. Edit `examples/tutorial/policies/m04.yaml` to drop the external-recipient guard (the `deny(...)` line), then run `cargo test -p cpex-tutorial`. Expect: `module4_external_email_denied_with_custom_code` fails, catching the regression. 2. Add a case. In `examples/tutorial/tests/policy_tests.rs`, add a row to the `cases` array in `module1_gates_by_authentication`, for example: ```rust - Case { tool: "search_repos", args: json!({ "visibility": "internal" }), want_allowed: true, want_code: None }, + Case { + tool: "search_repos", + args: json!({ "visibility": "internal" }), + want_allowed: true, + want_code: None, + }, ``` - Run `cargo test -p cpex-tutorial`. Expect: it passes (`search_repos` is open in `m01.yaml`). + Run `cargo test -p cpex-tutorial`. Expect: it passes (`search_repos` is an unconditional `allow` in `m01.yaml`). 3. Wire it into CI. `make test` runs the whole workspace test suite, including these. ## Checkpoint diff --git a/docs/content/docs/tutorial/11-groups.md b/docs/content/docs/tutorial/11-groups.md index 526ecb14..0d84f738 100644 --- a/docs/content/docs/tutorial/11-groups.md +++ b/docs/content/docs/tutorial/11-groups.md @@ -24,32 +24,54 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] - config: { ... as in module 2 ... } - -# One reusable bundle: the resolver every route needs, written once. + capabilities: [perform_http] + config: + role: user + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true # localhost speaks http; never set this in production + refresh_secs: 600 + leeway_seconds: 60 + +# One reusable bundle. Written once; joined by every route below. groups: identified: authentication: - keycloak routes: + # HR-only. Joins `identified` for the resolver, then requires the hr role. - tool: get_compensation - groups: identified # inherits keycloak; no authentication: line + groups: identified authorization: pre_invocation: - "require(role.hr)" + + # Engineering-only. Same resolver from the group; a different requirement. - tool: search_repos groups: identified authorization: pre_invocation: - "require(role.engineer)" + + # Open to any authenticated caller. Still joins `identified`, which is what + # resolves the token in the first place, but adds no role requirement. - tool: send_email - groups: identified # still resolves the token; no role required + groups: identified authorization: pre_invocation: - "require(authenticated)" ``` +The `keycloak` plugin is the module 2 resolver, unchanged. It declares `perform_http` because fetching the JWKS is an outbound call through the host's transport. + The `identified` group carries the one thing all three routes share. Each route joins it and adds only its own authorization, so identity is resolved the same way everywhere while policy still decides each outcome per caller. - **`groups:` is sugar over tags.** `groups: identified` is exactly `meta: { tags: [identified] }`. The field just makes that membership a first-class, discoverable spelling, and a runtime tag a host injects joins the group the same way. @@ -66,13 +88,13 @@ cargo run -p cpex-tutorial --example m11_groups ✓ ALLOWED { ... } ▸ evan (engineer) → get_compensation (resolved by the same group, denied at require(role.hr)) - ✗ DENIED [...] access denied + ✗ DENIED [routes.tool:get_compensation.pre_invocation[0]] access denied ▸ evan (engineer) → search_repos (same group, require(role.engineer) passes) ✓ ALLOWED { ... } ▸ alice (hr) → search_repos (denied at require(role.engineer)) - ✗ DENIED [...] access denied + ✗ DENIED [routes.tool:search_repos.pre_invocation[0]] access denied ▸ alice (hr) → send_email (group resolves her token; require(authenticated) passes) ✓ ALLOWED { ... } @@ -89,7 +111,7 @@ Every route resolved the caller's token through the same group, yet each outcome ## Checkpoint {{< details "Does the route repeat the group's authentication?" >}} -No. The route has no `authentication:` block, so it inherits the group's. Identity resolution stacks broad → narrow (global → group → route); a route joining `identified` runs the group's `keycloak` resolver without naming it again. +No. The route has no `authentication:` block, so it inherits the group's. Identity resolution stacks broad to narrow (global, then group, then route); a route joining `identified` runs the group's `keycloak` resolver without naming it again. {{< /details >}} {{< details "Group or the reserved `all` group?" >}} diff --git a/docs/content/docs/tutorial/12-subjects.md b/docs/content/docs/tutorial/12-subjects.md index 0308f282..7cdaf90c 100644 --- a/docs/content/docs/tutorial/12-subjects.md +++ b/docs/content/docs/tutorial/12-subjects.md @@ -22,16 +22,50 @@ The `subject:` argument on a `delegate(...)` step picks which principal the mint Two routes, two subjects. From [`policies/m12.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m12.yaml): ```yaml +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true + routes: - # On behalf of the caller: exchange the CALLER's token. + # On behalf of the caller. `subject: user` (the default) exchanges the + # caller's token, so a request with no token has nothing to exchange and + # delegation fails at require(delegation.granted). - tool: get_compensation - authentication: [keycloak] + authentication: + - keycloak authorization: pre_invocation: - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)" - "require(delegation.granted)" - # As the gateway itself: mint via the gateway's own client credentials. + # As the gateway itself. `subject: this_workload` uses the gateway's own + # client credentials, so it needs no caller token at all: the enforcement + # point, not the caller, holds the downstream authority. - tool: search_repos authorization: pre_invocation: @@ -39,6 +73,8 @@ routes: - "require(delegation.granted)" ``` +Both plugins declare `perform_http`: the resolver fetches the JWKS and the delegator calls the token endpoint, each through the HTTP transport the host installs (module 6). + - **`subject: user`** (the default) runs an RFC 8693 token exchange on the caller's inbound token. No caller token, nothing to exchange. - **`subject: this_workload`** runs an RFC 6749 `client_credentials` grant with the gateway's own `client_id` and secret, through the same `workday-oauth` plugin, reading no caller token at all. The `search_repos` route has no `authentication:` line, because this path needs no caller. diff --git a/docs/content/docs/tutorial/13-client.md b/docs/content/docs/tutorial/13-client.md index fbbbe3f1..ae030f83 100644 --- a/docs/content/docs/tutorial/13-client.md +++ b/docs/content/docs/tutorial/13-client.md @@ -31,23 +31,47 @@ Two changes from module 12. First, resolve the agent's token into the `client` s ```yaml plugins: - # role: client routes the inbound token to the `client` slot (not `subject`). + # Resolve the agent's inbound token into the CLIENT slot. `role: client` is + # what routes it to `client` (not `subject`); the delegation below then + # selects it with `subject: client`. The token is aud'd to cpex-gateway + # (the enforcement point), so this resolver trusts that audience. - name: keycloak-agent kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: - role: client # <-- the agent as an OAuth client + role: client header: Authorization claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial - audiences: [cpex-gateway] # the agent's token is aud'd to the gateway + audiences: [cpex-gateway] algorithms: [RS256] - decoding_key: { kind: jwks_url, url: "…/certs", insecure_http: true } + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true routes: + # The agent scopes its own client token down to the github-api audience. + # `subject: client` exchanges the CALLER's client token, so, like + # subject: user, an anonymous request has nothing to exchange and fails. - tool: search_repos - authentication: [keycloak-agent] + authentication: + - keycloak-agent authorization: pre_invocation: - "delegate(workday-oauth, target: github-api, audience: github-api, subject: client)" diff --git a/docs/content/docs/tutorial/14-passthrough.md b/docs/content/docs/tutorial/14-passthrough.md index ce163aae..1952ae06 100644 --- a/docs/content/docs/tutorial/14-passthrough.md +++ b/docs/content/docs/tutorial/14-passthrough.md @@ -23,17 +23,35 @@ There is nothing to add, only something to *leave out*: a plain resolver (as in ```yaml plugins: - - name: keycloak # validates the inbound token; that is its whole job + # A plain inbound resolver (as in module 2). Its only job here is to + # validate the caller's token; there is no delegator plugin at all. + - name: keycloak kind: identity/jwt hooks: [identity.resolve] - config: { role: user, ... as in module 2 ... } + capabilities: [perform_http] + config: + role: user + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 routes: + # Passthrough: validate the caller, authorize, and forward. No delegate + # step, so the token the caller presented is the token that flows on. - tool: search_repos - authentication: [keycloak] + authentication: + - keycloak authorization: pre_invocation: - - "require(authenticated)" # no delegate step, so the caller's token is forwarded + - "require(authenticated)" ``` | Shape | Step | What flows downstream | @@ -52,7 +70,7 @@ cargo run -p cpex-tutorial --example m14_passthrough ✓ ALLOWED { ... "repositories":[ ... ] } ▸ anonymous → search_repos (no token to forward, require(authenticated) denies) - ✗ DENIED [...] access denied + ✗ DENIED [routes.tool:search_repos.pre_invocation[0]] access denied ``` `alice`'s token was validated and the call went through with no exchange, so her token is what would reach the backend. The anonymous caller had no token to forward, so `require(authenticated)` denied it. diff --git a/docs/content/docs/tutorial/15-dual-principal.md b/docs/content/docs/tutorial/15-dual-principal.md index 8805bd97..cd9be1e7 100644 --- a/docs/content/docs/tutorial/15-dual-principal.md +++ b/docs/content/docs/tutorial/15-dual-principal.md @@ -26,22 +26,79 @@ Both credentials arrive on every call, on different headers, and a resolver pick ```yaml plugins: - - name: jwt-user # the human, on X-User-Token -> subject slot + # The human, on X-User-Token -> subject slot. + - name: jwt-user kind: identity/jwt - config: { role: user, header: X-User-Token, audiences: [cpex-tutorial], ... } - - name: jwt-agent # the agent, on Authorization -> client slot + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: user + header: X-User-Token + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + # The agent, on Authorization -> client slot (the acting party). + - name: jwt-agent kind: identity/jwt - config: { role: client, header: Authorization, audiences: [cpex-gateway], ... } + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: client + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-gateway] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true routes: + # The user authorizes (require role.hr on the SUBJECT), the token is minted + # on behalf of the user, and the agent is named as the actor. CPEX puts the + # actor on the wire per RFC 8693 delegation; whether `act` lands in the + # minted token is the token service's call (Keycloak drops it, see the doc). - tool: get_compensation - authentication: [jwt-user, jwt-agent] # both must resolve + authentication: + - jwt-user + - jwt-agent authorization: pre_invocation: - - "require(role.hr)" # authorize on the SUBJECT (the user) - - "delegate(workday-oauth, target: workday-api, audience: workday-api, - subject: user, actor: client)" + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user, actor: client)" - "require(delegation.granted)" + + # The guardrail: `actor:` only pairs with an on-behalf-of subject. Naming an + # actor while the subject is `this_workload` (a client_credentials grant that + # carries no actor_token) is a mistake, and CPEX denies it when the step runs + # rather than silently dropping the actor. + - tool: sync_index + authorization: + pre_invocation: + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: this_workload, actor: client)" ``` `subject: user` makes the user's token the RFC 8693 `subject_token`; `actor: client` *additionally* attaches the agent's token as the `actor_token`. That is one exchange carrying two principals, not a second leg. @@ -57,10 +114,10 @@ cargo run -p cpex-tutorial --example m15_dual_principal ✓ ALLOWED { ... } ▸ alice only, no agent token → get_compensation (actor credential missing) - ✗ DENIED [auth.malformed_header] header 'Authorization' missing ... (resolver 'jwt-agent') + ✗ DENIED [auth.malformed_header] header 'Authorization' missing from request (resolver 'jwt-agent' / ...) ▸ sync_index (subject: this_workload + actor: client) → rejected as an invalid combo - ✗ DENIED [...] `actor:` is not supported with `subject: caller_workload` or `subject: this_workload` ... + ✗ DENIED [routes.tool:sync_index.pre_invocation[0]] delegate `workday-oauth` error: invalid delegation config: `actor:` is not supported with `subject: caller_workload` or `subject: this_workload` ... ``` The first call carried both principals and went through. The second was missing the agent credential, and a dual-principal route needs both. diff --git a/docs/content/docs/tutorial/16-workload.md b/docs/content/docs/tutorial/16-workload.md index a16f51f7..66e182cf 100644 --- a/docs/content/docs/tutorial/16-workload.md +++ b/docs/content/docs/tutorial/16-workload.md @@ -45,20 +45,46 @@ One resolver for the SVID, and a `subject: caller_workload` delegation. From [`p ```yaml plugins: + # The workload SVID, on X-Workload-Token -> caller_workload slot. Validated + # against SPIRE's JWKS (the spire-oidc discovery provider on :8443), ES256, + # with the SVID's audience = the tutorial realm issuer (the draft requires + # the SVID's sole audience to be the authorization server it authenticates to). - name: jwt-workload kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] config: - role: caller_workload # -> the caller_workload slot + role: caller_workload header: X-Workload-Token + claim_mapper: standard trusted_issuers: - - issuer: http://spire-oidc:8443 # SPIRE, not the IdP - audiences: [http://localhost:8081/realms/cpex-tutorial] # SVID aud = the IdP - algorithms: [ES256] # SVIDs are EC-signed - decoding_key: { kind: jwks_url, url: "http://localhost:8443/keys" } + - issuer: http://spire-oidc:8443 + audiences: [http://localhost:8081/realms/cpex-tutorial] + algorithms: [ES256] + decoding_key: + kind: jwks_url + url: http://localhost:8443/keys + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true routes: + # The agent authenticates by SVID alone (no user/client token) and CPEX + # brokers a github-api-scoped token from it via the two-leg exchange. - tool: search_repos - authentication: [jwt-workload] # authenticate by the SVID alone + authentication: + - jwt-workload authorization: pre_invocation: - "delegate(workday-oauth, target: github-api, audience: github-api, subject: caller_workload)" diff --git a/docs/content/docs/tutorial/17-federation.md b/docs/content/docs/tutorial/17-federation.md index aa3a12b8..6159adbe 100644 --- a/docs/content/docs/tutorial/17-federation.md +++ b/docs/content/docs/tutorial/17-federation.md @@ -23,18 +23,41 @@ One resolver, two `trusted_issuers`. From [`policies/m17.yaml`](https://github.c plugins: - name: keycloak kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] config: role: user + header: Authorization claim_mapper: standard trusted_issuers: - - issuer: http://localhost:8081/realms/cpex-tutorial # home realm + # Home issuer: your own workforce realm. + - issuer: http://localhost:8081/realms/cpex-tutorial audiences: [cpex-tutorial] algorithms: [RS256] - decoding_key: { kind: jwks_url, url: "…/cpex-tutorial/…/certs" } - - issuer: http://localhost:8081/realms/cpex-partner # partner realm + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + # Partner issuer: a different realm, validated on ITS OWN keys. + - issuer: http://localhost:8081/realms/cpex-partner audiences: [cpex-tutorial] algorithms: [RS256] - decoding_key: { kind: jwks_url, url: "…/cpex-partner/…/certs" } # ITS OWN keys + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-partner/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + +routes: + # One route, one rule. It doesn't care WHICH issuer vouched for the caller, + # only that some trusted one did and the resolved subject has role.hr. + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" ``` Each entry is a full trust anchor: its own issuer string, its own JWKS, its own accepted audiences. Here both issuers are realms in the same Keycloak, but they could be entirely separate products. The resolver only matches `iss`. diff --git a/docs/content/docs/tutorial/18-attributes.md b/docs/content/docs/tutorial/18-attributes.md index c0a80a19..50b177d3 100644 --- a/docs/content/docs/tutorial/18-attributes.md +++ b/docs/content/docs/tutorial/18-attributes.md @@ -20,24 +20,50 @@ CPEX provisions them from a plain data file into the `data.*` namespace. Identit A data file of facts (note the top-level `data:` wrapper). From [`policies/attributes/controls.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/attributes/controls.yaml): ```yaml +data:` mapping. data: org: + # The region this deployment's data must stay in. data_region: eu controls: + # An operator's per-tool kill switch: flip a tool off without touching + # policy or code. Policy reads these facts and decides. tools: get_compensation: { enabled: false } # frozen by operations search_repos: { enabled: true } ``` -List it under `global.apl.attribute_files`, and read it in a rule. From [`policies/m18.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m18.yaml): +List it under `global.attribute_files`, and read it in a rule. From [`policies/m18.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m18.yaml): ```yaml global: - apl: - attribute_files: - - examples/tutorial/policies/attributes/controls.yaml # relative to repo root + # Deep-merged, in order, into one `data.*` tree at startup. Paths are + # relative to where you run the example (the repo root). + attribute_files: + - examples/tutorial/policies/attributes/controls.yaml + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: user + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 routes: + # Enabled/disabled is an operator fact, read from data.* per request. Each + # route consults its own tool's flag; policy never hard-codes the answer. - tool: get_compensation authentication: [keycloak] authorization: @@ -46,6 +72,15 @@ routes: - when: "data.controls.tools.get_compensation.enabled == false" do: - "deny('get_compensation is disabled by operations', 'ops.tool_disabled')" + + - tool: search_repos + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" + - when: "data.controls.tools.search_repos.enabled == false" + do: + - "deny('search_repos is disabled by operations', 'ops.tool_disabled')" ``` The whole file flattens into the bag: `data.org.data_region`, `data.controls.tools.get_compensation.enabled`, and so on. Multiple files deep-merge in order, and the merge is fail-fast: two files setting the same leaf differently is a load error, and a file missing its `data:` wrapper is rejected. A config mistake stops startup rather than quietly mis-routing. diff --git a/docs/content/docs/tutorial/_index.md b/docs/content/docs/tutorial/_index.md index 50417e68..6f929e83 100644 --- a/docs/content/docs/tutorial/_index.md +++ b/docs/content/docs/tutorial/_index.md @@ -32,12 +32,12 @@ Budget 15 to 25 minutes per module: about 3 to 4 hours for modules 0 to 10 and t | 1 | [Hello, enforcement]({{< relref "01-hello" >}}) | Stand up an enforcement point; a route with `require()`; allow vs. deny | no | | 2 | [Who's calling? (Identity)]({{< relref "02-identity" >}}) | Resolve real JWTs from Keycloak into roles and permissions | yes | | 3 | [Shaping data]({{< relref "03-shaping" >}}) | `result:` field pipelines that redact and mask per permission | yes | -| 4 | [Effects & sequencing]({{< relref "04-effects" >}}) | Ordered effects, halt-on-deny, custom deny codes, auditing | yes | +| 4 | [Effects & sequencing]({{< relref "04-effects" >}}) | Ordered effects, halt-on-deny, custom deny codes, auditing | no | | 5 | [Delegating decisions (PDP)]({{< relref "05-pdp" >}}) | Hand a decision to CEL or Cedar; CPEX enforces the verdict | yes | | 6 | [Scoped credentials (Delegation)]({{< relref "06-delegation" >}}) | Mint a downstream-scoped token via RFC 8693 exchange | yes | | 7 | [Information flow (Tainting)]({{< relref "07-tainting" >}}) | Carry session state across requests; block write-down | yes | | 8 | [Human in the loop]({{< relref "08-elicitation" >}}) | Suspend an operation for human approval, then resume | yes | -| 9 | [Write your own plugin]({{< relref "09-custom-plugin" >}}) | Build a custom plugin with the SDK; reference it from policy | no | +| 9 | [Write your own plugin]({{< relref "09-custom-plugin" >}}) | Build a custom plugin with `cpex::prelude`; reference it from policy | no | | 10 | [Testing your policy]({{< relref "10-testing" >}}) | Table-driven allow/deny tests that run in CI | no | | 11 | [Organizing policy (Groups)]({{< relref "11-groups" >}}) | Factor shared identity/authz into reusable bundles routes join | yes | | 12 | [Delegation subjects]({{< relref "12-subjects" >}}) | Mint a downstream token as the caller (`user`) or as the gateway itself (`this_workload`) | yes | diff --git a/docs/content/docs/tutorial/capstone.md b/docs/content/docs/tutorial/capstone.md index e5a583b3..83163837 100644 --- a/docs/content/docs/tutorial/capstone.md +++ b/docs/content/docs/tutorial/capstone.md @@ -20,19 +20,25 @@ One policy ([`policies/capstone.yaml`](https://github.com/contextforge-org/cpex/ `get_compensation` is the densest route, and it runs each control you built in order: ```yaml -- tool: get_compensation - authentication: [keycloak] # module 2: resolve the caller from a JWT - authorization: - pre_invocation: - - "require(role.hr)" # module 1/2: gate by role - - "delegate(workday-oauth, target: workday-api, audience: workday-api)" # module 6 - - "taint(secret, session)" # module 7: mark the session - - "run(audit-log)" # module 4: record the attempt - result: - ssn: "str | redact(!perm.view_ssn)" # module 3: shape the output per permission - salary: "int | redact(!role.hr)" +routes: + # HR lookup: gate on role, mint a scoped downstream token, taint the + # session, audit, then redact fields by permission on the way out. + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api)" + - "taint(secret, session)" + - "run(audit-log)" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" ``` +Each step maps to a module: `authentication` resolves the caller from a JWT (module 2), `require(role.hr)` gates by role (modules 1 and 2), `delegate(...)` mints a scoped token (module 6), `taint(...)` marks the session (module 7), `run(audit-log)` records the attempt (module 4), and `result:` shapes the output per permission (module 3). The `audit-log` plugin is the tutorial's `tutorial/audit-line` host plugin from module 4, and the `keycloak` and `workday-oauth` plugins declare `perform_http` because they reach the IdP through the host's HTTP transport. + The one control the Overview did not show is `delegate(...)`: before the backend call, CPEX exchanges the caller's token for a fresh one scoped to the `workday-api` audience (RFC 8693, [module 6]({{< relref "06-delegation" >}})), so the backend never receives the caller's original credential. Everything else on this route you have already seen on its own; the capstone just runs them together, in sequence, on one operation. ## Run it @@ -53,9 +59,11 @@ Two behaviors carry the whole idea. ✓ ALLOWED { ... "ssn":"[REDACTED]" ... } ▸ evan (engineer) → get_compensation (denied, not HR) - ✗ DENIED [...] access denied + ✗ DENIED [routes.tool:get_compensation.pre_invocation[0]] access denied ``` +Each allowed call also prints one audit line to stderr: the plugin name, the entity, the caller's subject id and roles, and the tool name with its argument keys. Argument values are never logged. Evan's call writes no line, because `require(role.hr)` halts the pipeline before `run(audit-log)`. + **Information follows the session.** ``` diff --git a/docs/content/docs/use-cases.md b/docs/content/docs/use-cases.md index 8e7efed6..6b07fe32 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. CPEX ships as its `policy` filter (the `cpex-policy-engine` feature), so the gateway parses MCP JSON-RPC, runs the full policy pass, and only then forwards a scoped request upstream: +[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: ![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) @@ -34,8 +34,9 @@ Who may call this tool at all? The `get_compensation` route opens with an attrib ```yaml routes: - tool: get_compensation - pre_invocation: - - "require(role.hr)" + authorization: + pre_invocation: + - "require(role.hr)" ``` Bob and Eve pass (HR role in their tokens). Alice is denied with a JSON-RPC error envelope before the request ever leaves the gateway; the backend never sees it. The demo resolves two identities per request, the human (`X-User-Token`) and the client (`Authorization`), each validated by its own `identity/jwt` plugin against Keycloak. @@ -89,7 +90,7 @@ Run it: [`scenarios/08-bob-taint-deny.sh`](https://github.com/praxis-proxy/demos ## 5. PII guardrails on arguments -Session taint is state-based; this control is content-based, and they complement each other. A validator plugin scans tool arguments for sensitive patterns and denies before dispatch: +Session taint is state-based; this control is content-based, and they complement each other. A validator plugin scans tool arguments for sensitive patterns and denies before dispatch. The demo registers PPE's reference PII scanner as a host plugin; it is not a CPEX builtin (see [Builtins]({{< relref "/docs/builtins#not-builtins-the-pii-scanner-and-audit-logger" >}}) for how a Rust host registers it, and for alternatives): ```yaml - name: pii-scan @@ -104,7 +105,7 @@ Session taint is state-based; this control is content-based, and they complement Bob pasting an SSN into an email body gets a deny, and the audit record of the attempt is still written: the route runs `run(audit-log)` before `run(pii-scan)`, so observation happens before the gate blocks. Flip `mode: deny` to `audit` to shadow-test the scanner against real traffic first (see [Patterns]({{< relref "/docs/patterns#shadow-rollout-with-audit-mode" >}})). -Run it: [`scenarios/07-bob-pii-deny.sh`](https://github.com/praxis-proxy/demos/blob/main/demos/policy-engine/scenarios/07-bob-pii-deny.sh). More: [Builtins]({{< relref "/docs/builtins" >}}). +Run it: [`scenarios/07-bob-pii-deny.sh`](https://github.com/praxis-proxy/demos/blob/main/demos/policy-engine/scenarios/07-bob-pii-deny.sh). More: [Builtins]({{< relref "/docs/builtins#reference-plugins-in-a-rust-host" >}}). ## 6. Human-in-the-loop approval @@ -112,12 +113,13 @@ Some actions should not happen on the caller's authority alone. `adjust_compensa ```yaml - tool: adjust_compensation - pre_invocation: - - "require(role.hr)" - - when: "args.amount > 10000" - do: - - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve a compensation adjustment\", timeout: 24h)" - - "run(audit-log)" + authorization: + pre_invocation: + - "require(role.hr)" + - when: "args.amount > 10000" + do: + - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve a compensation adjustment\", timeout: 24h)" + - "run(audit-log)" ``` The gateway never blocks. It suspends the call, answers the agent with JSON-RPC `-32120` and an elicitation id, and drives an OIDC CIBA backchannel request to Keycloak, which pushes the prompt to the manager's device. The `scope` binds the approval to the live amount, so a sign-off cannot be replayed against a larger change. @@ -180,7 +182,7 @@ The whole demo is one command from [`demos/policy-engine`](https://github.com/pr Eleven scripted scenarios cover every control above, and `agent/chat.py --persona bob` drives the same gateway from an LLM chat agent, approvals included. -Prefer the library-embedded flavor? The in-repo [Tutorial]({{< relref "/docs/tutorial" >}}) builds the same scenario with CPEX as a Rust crate inside the host process, one capability per module. +Prefer the library-embedded flavor? The in-repo [Tutorial]({{< relref "/docs/tutorial" >}}) builds the same scenario with CPEX as a Rust crate inside the host process, one capability per module. The demo's gateway reaches Keycloak on a private Docker network, which needs the private-destination opt-in; a production deployment should install a transport with its own egress rules instead (see [Builtins]({{< relref "/docs/builtins#private-destinations" >}})). ## What to read next diff --git a/docs/content/docs/vision.md b/docs/content/docs/vision.md index 54b141eb..72b0f0cc 100644 --- a/docs/content/docs/vision.md +++ b/docs/content/docs/vision.md @@ -50,6 +50,8 @@ Enforcement is three concerns, separated cleanly: APL leads. CMF gives policy a uniform thing to evaluate across tools, A2A, inference, prompts, and resources. The pipeline is the supporting execution layer: it is what lets a policy effect call a PDP, mint a token, scan for PII, or write an audit record. You reach for it when you extend the set of effects available to policy, not when you write policy. +Since 0.3, all three layers come from the praxis-policy engine (PPE), the engine CPEX's own core was extracted into. CPEX consumes it from crates.io at an exact version and adds what an embedder needs around it: a Rust facade, Python and Go bindings, a C ABI, an out-of-process host for existing Python plugins, and a Biscuit delegator. The policy language and the model on this page are the same either way. + ## The policy spectrum Different controls belong at different points. CPEX runs the same way at each of them, so you place a policy where its enforcement point is, not where the framework forces it. diff --git a/docs/operations/valkey-session-store.md b/docs/operations/valkey-session-store.md index 7e43edbc..156f5bad 100644 --- a/docs/operations/valkey-session-store.md +++ b/docs/operations/valkey-session-store.md @@ -1,6 +1,6 @@ # Operating the Valkey Session Store -The Valkey-backed `SessionStore` (`cpex-session-valkey`) persists per-session +The Valkey-backed session store (`kind: valkey`, from the praxis-policy builtins) persists per-session security **taint labels** across process restarts and shares them across gateway nodes. Those labels drive information-flow authorization, so the backend is **fail-closed**: any store error denies the request rather than @@ -10,10 +10,14 @@ This runbook covers the operator-owned controls the backend depends on but **cannot enforce from the client**. Getting them wrong silently weakens the security guarantee, so treat this as part of the deployment contract. -> Build note: the backend is compiled into the FFI artifact only with the -> `valkey` cargo feature (`cargo build -p cpex-ffi --features valkey`). -> Without it, the default in-process memory store is used and nothing here -> applies. +> Build note: since 0.3.0 the Valkey store is compiled into every CPEX +> surface (the `cpex` facade, the Python package, the Go module and the FFI +> artifact); there is no feature to enable. It is used only when the config +> declares `global.session_store` with `kind: valkey`. Without it, the default +> in-process memory store is used and nothing here applies. +> +> Labels written by CPEX 0.2.x are read back unchanged by 0.3.0: the key +> layout under `taint:v1` is the same. --- @@ -28,7 +32,7 @@ global: endpoint: valkey.internal:6379 # or rediss://valkey.internal:6379 tls: true username: gateway # ACL user (see §3) - password: ${VALKEY_PASSWORD} # inject from a secrets manager + password: "" # see Credentials in §3 key_prefix: taint:v1 # default; bump only on schema change ttl_seconds: 86400 # optional sliding TTL (see §4) max_session_lifetime_seconds: 86400 # enables the TTL-soundness warning @@ -52,10 +56,10 @@ maxmemory-policy noeviction Why: with any `*-lru` / `*-lfu` / `*-random` / `volatile-*` policy, Valkey can **silently evict** a live session's taint set under memory pressure. A later read then returns an empty set, the gateway under-labels, and may -**over-authorize** — the exact fail-open this store exists to prevent. With +**over-authorize**: the exact fail-open this store exists to prevent. With `noeviction`, a full instance instead fails *writes* with an error, which the backend converts into a denied request (fail-closed). The client cannot set -or enforce this — it is a server config you own. +or enforce this; it is a server config you own. Note the volatile policies are **not** safe here even though they sound scoped: the label keys carry a TTL (§4), so `volatile-lru`/`volatile-ttl` @@ -68,21 +72,21 @@ valkey-cli CONFIG GET maxmemory-policy # must be "noeviction" valkey-cli CONFIG GET maxmemory # must be a non-zero bound ``` -- Alert if `evicted_keys` in `INFO stats` is ever non-zero — it must stay `0`. +- Alert if `evicted_keys` in `INFO stats` is ever non-zero; it must stay `0`. - Watch `used_memory` vs `maxmemory` and the OOM write-error rate so you scale before the instance fills. -This is operator-owned contract — the backend does **not** verify it for you. +This is operator-owned contract: the backend does **not** verify it for you. A best-effort startup `CONFIG GET maxmemory-policy` self-check that warns when the policy is not `noeviction` is a deferred follow-up (it would require the connection pool to dial at config-load, which today it does not). Until then, -the authoritative control — and its monitoring — is yours. +the authoritative control, and its monitoring, is yours. --- ## 3. TLS and least-privilege ACL -**TLS is required for any non-localhost endpoint** — the backend rejects a +**TLS is required for any non-localhost endpoint**: the backend rejects a plaintext, non-localhost config at load. Security labels reveal which sessions carry sensitive taint; in plaintext they are exposed to passive interception and active MITM (label injection/suppression). Prefer **mTLS** so a stolen ACL @@ -98,7 +102,7 @@ tls-ca-cert-file /etc/valkey/tls/ca.crt # tls-auth-clients yes # require client certs (mTLS) ``` -**Minimum ACL** for the gateway user — it only needs `SADD`, `SMEMBERS`, +**Minimum ACL** for the gateway user. It only needs `SADD`, `SMEMBERS`, `EXPIRE`, and (for the self-check) `CONFIG|GET`, scoped to the key prefix: ``` @@ -108,11 +112,15 @@ ACL SETUSER gateway on >$STRONG_SECRET resetchannels -@all \ ``` - `~taint:v1:*` confines key access to the label namespace. -- Grant `+config|get` (the subcommand) — never bare `+config`. +- Grant `+config|get` (the subcommand), never bare `+config`. - Consider giving `CONFIG|GET` to a separate health/admin user so the hot-path writer's surface stays minimal. -**Credentials:** never hard-code the secret; inject from a secrets manager. +**Credentials:** never commit the secret. The `password` field is read as a +literal: CPEX does no `${ENV}` substitution in config, and this field does not +take a `secrets:` reference. Render the document from your secrets manager at +deploy time (for example a templated Kubernetes Secret mounted as the policy +file), and keep that file readable only by the CPEX process. Valkey ACL users support multiple password hashes, enabling overlap rotation (add new, roll clients, drop old) with no downtime; with mTLS, rotate the client cert via `tls-auto-reload-interval`. @@ -126,7 +134,7 @@ sliding TTL: refreshed on every load and append. **Soundness rule (R8):** a TTL is sound **only if it is ≥ the maximum lifetime of a session identity.** A shorter TTL lets accumulated taint expire while the -session is still usable — a "downgrade-by-waiting": an adversary holds a +session is still usable, a "downgrade-by-waiting": an adversary holds a tainted session, waits out the TTL, and resumes it clean. If your gateway's session identities are not bounded (e.g. header- or identity-derived ids with no expiry), **leave the TTL off.** @@ -146,14 +154,14 @@ expire between requests and silently drop taint. Alert on The label keyspace is a **security system-of-record**, not a cache. Promoting Valkey to hold an authorization input inverts its default durability -assumptions, so its on-disk persistence is part of the deployment contract — +assumptions, so its on-disk persistence is part of the deployment contract: alongside `noeviction` (§2) and the TTL rule (§4), this closes the third way a label can silently vanish. **The failure mode this closes.** A `SADD` is acknowledged to the gateway, the node crashes before the write is fsync'd to disk, and the label is **gone**. On -restart (or replica failover) the next read returns a normal `Ok(empty)` — -*not* an error — so fail-closed never trips. The request proceeds with **less +restart (or replica failover) the next read returns a normal `Ok(empty)`, +*not* an error, so fail-closed never trips. The request proceeds with **less taint than actually accumulated**: a silent downgrade. Critically, this is **invisible to every alarm in §7** because nothing errors, which is exactly why it has to be closed at the server-config layer rather than detected at runtime. @@ -177,7 +185,7 @@ appendfsync everysec # or: always **Failover interaction with §6.** The "fail over to a healthy primary" guidance inherits Valkey's **asynchronous** replication: a failover can promote a replica -that is missing the most recent un-replicated appends — the same downgrade by a +that is missing the most recent un-replicated appends: the same downgrade by a different path. Tighten replication durability (e.g. `min-replicas-to-write` / `min-replicas-max-lag`, or `WAIT`-aware fronting) if your failover budget demands it. @@ -192,7 +200,7 @@ startup `CONFIG GET appendonly` / `appendfsync` warning is a deferred follow-up ## 6. Topology, availability, and blast radius - **Single endpoint, primary-only reads.** The backend reads and writes one - endpoint and never read-splits to replicas — replica replication lag would + endpoint and never read-splits to replicas, because replica replication lag would return stale (smaller) label sets, a silent downgrade. Achieve HA by pointing `endpoint` at a fronting address (K8s Service, VIP, or proxy) that fails over to a healthy primary. Client-side Sentinel/Cluster are not supported in v0. @@ -204,7 +212,7 @@ startup `CONFIG GET appendonly` / `appendfsync` warning is a deferred follow-up `command_timeout_ms` / `connect_timeout_ms` budgets bound how long a request waits before failing closed. -- **Anonymous/sessionless traffic is unaffected** — requests with no resolved +- **Anonymous/sessionless traffic is unaffected**: requests with no resolved session id never touch the store, so a Valkey outage does not deny them. - **No live-reload (v0).** Changing the `session_store` config requires a @@ -228,10 +236,14 @@ startup `CONFIG GET appendonly` / `appendfsync` warning is a deferred follow-up ``` docker compose -f deploy/valkey-compose.yml up -d -VALKEY_TEST_URL=redis://127.0.0.1:6379 \ - cargo test -p cpex-session-valkey --test valkey_store_integration -- --ignored +PARITY_VALKEY_URL=redis://127.0.0.1:6379/0 cargo test -p cpex-parity-harness ``` +The parity harness replays the session-taint scenarios against the store and +checks that labels a 0.2.3 run wrote are read back unchanged. It runs +`FLUSHDB` on the database it is given, so point it at a throwaway instance. +The store's own integration tests live in the praxis-policy repository. + The compose file runs a `noeviction`-configured Valkey. It has no TLS/ACL and -runs RDB-default (non-durable, no AOF) — those are dev-only conveniences; +runs RDB-default (non-durable, no AOF). Those are dev-only conveniences; production must add TLS/ACL per §3 and AOF persistence per §5. diff --git a/docs/plans/2026-10-07-001-refactor-adopt-ppe-core-plan.md b/docs/plans/2026-10-07-001-refactor-adopt-ppe-core-plan.md new file mode 100644 index 00000000..8804e544 --- /dev/null +++ b/docs/plans/2026-10-07-001-refactor-adopt-ppe-core-plan.md @@ -0,0 +1,663 @@ +--- +title: "refactor: Adopt PPE as the CPEX core for 0.3.0" +type: refactor +status: completed +date: 2026-10-07 +origin: docs/brainstorms/ppe-core-adoption-requirements.md +--- + +# refactor: Adopt PPE as the CPEX core for 0.3.0 + +## Summary + +Capture 0.2.3 decision snapshots first, while the old engine still exists. Then switch the workspace to PPE 0.4.1 from crates.io in one cut-over and port each kept surface onto it in dependency order: facade, Python host, FFI, biscuit, Python bindings, Go, tutorial. Finish with config and fixture migration, the acceptance and parity suites, docs, Makefile/CI/release machinery, and 0.3.0 release prep. + +--- + +**2026-10-13 release update:** The maintainer selected released PPE 0.5.0 for the final CPEX pin, superseding the original 0.4.x constraint. CPEX adopts its API and documented security changes, reruns parity, and tracks further upstream work in [PPE #208](https://github.com/praxis-proxy/policy/issues/208) and [PPE #209](https://github.com/praxis-proxy/policy/issues/209). Those fixes will be implemented in a separate PPE PR and adopted from a released version. + +## Problem Frame + +CPEX carries a drifting copy of the engine that now lives in PPE. Every kept CPEX surface is wired to the old crates and the old `PluginManager` name. Full context in the origin document. + +--- + +## Requirements + +Carried from the origin document; R-IDs are the origin's. + +- R1-R5. crates.io-only PPE 0.4.x dependency; remove replaced crates and pii/audit; keep biscuit, Python host and FFI, ported. +- R6-R8. Clean rename to `PolicyEngine` on every surface; Python `PluginManager` alias is the only shim. +- R9-R11. Bindings ship every non-experimental PPE builtin (adds OPA, CIBA, api-key, Vault), no pii/audit, and outbound-HTTP builtins work without integrator transport code. +- R12-R14. Config follows PPE exactly; all shipped configs ported; key-by-key migration guide. +- R15-R18. CPEX keeps full standalone docs, revised and enriched with current PPE material; reference-plugin registration documented; 0.3.0 changelog lists every break. +- R19-R21. Existing suites pass; new CPEX-side acceptance suite; parity against 0.2.3. +- R22-R24. Makefile, CI and release workflows updated; release as 0.3.0; signed-off short conventional commits without AI trailers. +- R25. Python support keeps working end to end: PyO3 wheel and the `isolated_venv` Python plugin host, with e2e suites green in CI. +- R26. CPEX moves to edition 2024 and resolver 3, matching PPE, as its own change. + +**Origin actors:** A1 (Rust embedder), A2 (Python integrator), A3 (Go integrator), A4 (Operator), A5 (Maintainer) +**Origin acceptance examples:** AE1 (covers R6, R7), AE2 (covers R12, R14), AE3 (covers R10, R17), AE4 (covers R11), AE5 (covers R21) + +--- + +## Scope Boundaries + +- Carried from origin: no config migration tool or legacy-key acceptance; no Rust or Go aliases; no pii/audit in bindings; no experimental quota in bindings; no PPE feature work beyond upstreaming fixes CPEX needs; no moving or retiring CPEX docs; no Praxis-side changes. +- No new Python API beyond the rename, the alias, the private-destination keyword argument and secret refresh; the Python plugin host stays a Rust facade feature and is not registered by the wheel or FFI, matching 0.2.3. +- No PyPI wheel publish workflow (none exists today; wheels stay Makefile-built). + +### Deferred to Follow-Up Work + +- Idempotent PPE `install_builtins`: [confirmed defect, PPE #208](https://github.com/praxis-proxy/policy/issues/208). Keep the binding guards; document double install as a Rust host error until the upstream patch ships. Any adopted fix ships in a new PPE release and gates the tag on a new parity run. +- A private-destination allow-list in PPE: [PPE #209](https://github.com/praxis-proxy/policy/issues/209). It is outside this cut-over's feature scope. Meanwhile warn at binding startup when the global opt-in is enabled. +- Distinct `auth.egress_denied` code for identity-jwt in PPE: upstream issue. +- Capture PyO3, Python host venv and HTTP transport learnings with a compound note after 0.3.0 ships. +- Any final deprecation release for removed crate names on crates.io (they stay at 0.2.3; the changelog says so). + +--- + +## Context & Research + +### Relevant Code and Patterns + +- Facade: `crates/cpex/src/lib.rs` re-exports map 1:1 onto `praxis-policy:crates/ppe/src/lib.rs` (`install_builtins`, `register_builtin_plugins`, `builtin_pdp_factories`, `builtin_session_store_factories`, `register_apl`, `AplOptions`, `SessionStore*`, `PdpFactory`). PPE adds `OpaPdpFactory`, `CibaApproverFactory`, `ApiKeyIdentityFactory`, `VaultSecretProviderFactory`, `registry_with_vault`, `HyperTransport`, `install_default_http_transport`, `prelude`. +- PPE crates have no root re-exports: `PolicyEngine` is at `engine::PolicyEngine`, `PipelineResult` at `executor::PipelineResult`. `bindings/python` imports both from the CPEX crate root today. +- Python host (`crates/cpex-hosts-python/src/factory.rs`): `IsolatedVenvFactory`, `KIND = "isolated_venv"`. Plugin, factory, registry, context, payload, extensions and raw-credential types are public in PPE with the same shapes. Gaps: `CpexConfig` becomes `config::PolicyConfig`, `ManagerConfig` becomes `engine::PolicyEngineConfig`, legacy `hooks::types::hook_names` is gone. +- Hook names changed. CPEX 0.2.3 still accepts the legacy unprefixed names (`tool_pre_invoke`, `tool_post_invoke`, `prompt_pre_fetch`, `prompt_post_fetch`, `resource_pre_fetch`, `resource_post_fetch`, `identity_resolve`, `token_delegate` in `crates/cpex-core/src/hooks/types.rs`). PPE removed them and rejects them at load with a suggestion (`praxis-policy:crates/ppe-core/src/config.rs` tests). Dispatched names are `cmf.*` (with `cmf.prompt_pre_invoke` replacing `prompt_pre_fetch`), `identity.resolve` and `token.delegate`. The Python host's `payload_kind_for_hook` and `is_credential_hook` (`crates/cpex-hosts-python/src/conversion.rs`) match on the legacy names, so a mechanical swap would route identity and delegation hooks to `Generic` and stop passing raw credentials to Python plugins. +- PPE requires plugins that use `jwks_url`, OAuth delegation or CIBA to declare the `perform_http` capability, or the engine refuses to start (`praxis-policy:CHANGELOG.md`). No CPEX config declares it today. +- Egress refusal codes: delegator-oauth reports `delegation.egress_denied` and CIBA `elicitation.egress_denied`. identity-jwt maps a refused JWKS fetch to a recoverable key-source error, so initialization soft-fails and requests report `auth.jwks_unavailable`, the same code an IdP outage produces (`praxis-policy:crates/builtins/src/plugins/identity_jwt/config.rs`). +- PPE reference plugins are `publish = false` and depend on PPE's workspace-path core, so a git dependency would build a second, incompatible `praxis-policy-core`; `deny.toml` also denies unknown git sources. +- Vault in PPE only renews and re-reads secrets when the host calls `PolicyEngine::refresh_secrets()`; `secrets_last_success` reports health. +- PPE `install_builtins` calls `register_apl` each time and `register_visitor` appends, so a second install adds a second APL visitor. +- `.github/workflows/ci.yml` already has a `python-e2e` job running `make test-python-e2e` with skips-as-failures on every non-draft PR. No workflow builds the Python wheel. Go tests are not run in CI today. +- FFI (`crates/cpex-ffi/src/lib.rs`, `crates/cpex-ffi/src/apl.rs`): 18 `extern "C"` functions with a flat `cpex_*` prefix, `FFI_ABI_VERSION = 2`, own tokio `SHARED_RUNTIME`, public `CpexManagerInner { manager }` used by `examples/go-demo/ffi`. `cpex_apl_install` duplicates PPE's `install_builtins` body. No tracked C header; prototypes live in cgo preambles in `go/cpex/ffi.go`, `go/cpex/abi.go`, `go/cpex/apl.go`, `go/cpex/manager.go`. +- Go link flags: `go/cpex/ffi.go` (`-lcpex_ffi`, darwin `-framework CoreFoundation -framework Security`). go-demo pins a real module version. +- Python package: `bindings/python/python/cpex/__init__.py` exports `PipelineResult`, `PluginManager`; stubs in `bindings/python/python/cpex/_lib.pyi`; classes registered via `#[pyclass(name = ...)]` in `bindings/python/src/manager.rs` and `bindings/python/src/result.rs`. +- PPE transport: `install_default_http_transport` is set-once; `HyperTransport` builds without a runtime and blocks private/loopback destinations unless `with_allow_private_destinations()` is used. Builtins needing it: identity-jwt (jwks_url), api-key (http directory), delegator-oauth, elicitation-ciba, Vault. PPE's `install_builtins` does not register Vault; wire it with `set_secret_providers(registry_with_vault(transport))` using the same transport instance. +- PPE wire pinning example: `praxis-policy:crates/ppe-core/tests/wire_compatibility.rs`. Removed-key table: `REPLACED_KEYS` in `praxis-policy:crates/ppe-core/src/config.rs`. +- Stale config keys in kept dirs: `examples/go-demo` (`plugin_settings`, `global.policies`, route `plugins:` lists, `priority:`), `examples/tutorial` (`apl:` wrapper in m05, m18, capstone, capstone-nodeleg; pii/audit in m04 and capstones), `go/cpex/manager_test.go` (`plugin_settings`), `crates/cpex-hosts-python` (top-level `plugin_dirs` in `src/plugin.rs` and `tests/config_e2e.rs`), `bindings/python/tests` fixtures (`priority:`, plus `pii_deny.yaml` and `pii_redact.yaml` using pii/audit). `go/cpex/apl_test.go` and the FFI APL test use `audit/logger`. +- Release machinery: `.github/workflows/release.yaml` (hardcoded publish order, idempotent skip, `cargo package --exclude` dry run), `.github/workflows/ci.yml` (`package` job excludes), `.github/workflows/tutorial.yaml` (path triggers on `crates/**`, `builtins/**`), `.github/workflows/release-ffi.yaml` with `scripts/release/build-artifact.sh` and `scripts/download-ffi-artifact.sh`, `release.toml`, Makefile `examples-run`, `publish-dry`, `tag`, `release*`, `go-*`, `bindings-python-*`, `tutorial-check*`. +- Post-port CPEX deltas touching core or builtins since `aed0f15`: `builtins/pdps/cel/src/resolver.rs`, `builtins/plugins/identity-jwt` tests and manifest, `crates/cpex/src/lib.rs` (python-host feature gate). + +### Institutional Learnings + +- No `docs/solutions/` in either repo. From history: + - The 0.2.2 release broke on a missing publish-order entry; the idempotent skip in `release.yaml` must stay. + - macOS Go static link needed extra frameworks (`b40b22a`); a changed dependency graph can add more. + - Docs link CI (lychee plus GitHub tree API) fails on links into deleted paths; link updates are part of the work. + - Renaming an optional dependency silently renames its feature and makes stale `cfg(feature)` predicates false; deny `unexpected_cfgs` (from `docs/plans/2026-08-08-001-feat-praxis-policy-engine-port-plan.md`). + - Removing a builtin needs feature, dependency, re-export, registration and factory-count assertions removed together (`praxis-policy:docs/dev/port-provenance.md`). + - Reconcile tests by executed count and list ignored tests explicitly. + +--- + +## Key Technical Decisions + +- Parity baseline is golden snapshots captured from 0.2.3, not a side-by-side build: two versions of the same engine in one workspace is fragile, and the first unit runs while the old engine is still present. +- One cut-over commit for all workspace manifests and the facade: the workspace never resolves two engines at once. Cargo must load every member's manifest to resolve the workspace, so the cut-over also repoints every kept crate's `Cargo.toml` to the crates.io PPE crates (kept crates that the facade depends on, such as the Python host and biscuit, use `praxis-policy-core` directly to avoid a cycle). Source ports follow as their own commits; until each lands, only that crate fails to compile, and `cargo build -p cpex` without `python-host` works right after the cut-over. Work happens on one integration branch so `main` never sees the red window. +- PPE is pinned exactly (`=0.5.0`) in the workspace, not by caret. Further engine fixes land in separate PPE PRs and are adopted from released versions. Moving the pin requires rerunning parity (U11). +- Hook names follow PPE. The Python host remaps payload-kind and credential-hook detection onto PPE's dispatched names, the parity fixtures carry an explicit reviewed legacy-to-PPE hook rename table, and the migration guide and changelog list the renames for config authors, Python plugin authors and Go `InvokeByName` callers. +- C ABI symbols rename `cpex_manager_*` to `cpex_engine_*`, the exported inner struct renames to match, and `FFI_ABI_VERSION` goes to 3 so a stale Go build fails its ABI check loudly. Go names follow (`NewPolicyEngine`, `NewPolicyEngineDefault`, `PolicyEngine` type). +- Bindings build one transport per engine with private destinations blocked by default, and a per-binding opt-in (Python constructor keyword argument, Go option, FFI flag) enables `with_allow_private_destinations()`. The switch lives in host code only; no surface reads it from the policy document. The same transport instance is passed to Vault. Tutorial and local demos turn the opt-in on. +- FFI ABI shape for the opt-in: `cpex_apl_install` takes a flags argument with a bit for private destinations. It installs builtins, builds the transport, sets it, and wires Vault with the same instance, and it is idempotent per handle (a second call is a no-op). The one-shot `cpex_engine_new(yaml)` path stays builtin-free and transport-free, as in 0.2.3; hosts that register their own factories install a transport through the same entry point or bring their own. +- Bindings expose secret refresh because they now ship Vault: an FFI refresh entry point returning the refresh report plus a last-success query, Go `RefreshSecrets`, and Python `refresh_secrets`. This is the one deliberate addition to the Python API beyond the rename and alias. +- FFI `cpex_apl_install` delegates to PPE's `install_builtins` instead of reproducing it. +- Workspace denies `unexpected_cfgs` before features are renamed. +- One CPEX wire-compat list pins plugin `kind` strings and hook names. The FFI test asserts it, and the Python bindings test reads the same fixture, so a new PPE builtin is one edit. Other tests only assert pii and audit are absent. +- Acceptance suites live beside each surface's existing tests (Python bindings pytest, Go tests, Python host e2e) rather than in a new top-level harness. The Rust facade is a re-export, so it keeps only its U3 facade test; PPE's own e2e suites cover the engine. +- Post-port deltas are diffed against PPE 0.4.1 first; real fixes go upstream as a 0.4.x patch release, which then gates the 0.3.0 tag. +- Edition 2024 lands as its own unit after the surface ports, not before the cut-over: migrating code first would spend effort on crates U3 deletes, and edition fixes need a compiling crate. Kept crates and examples move together with resolver 3, and the clippy MSRV setting is aligned with PPE's toolchain. +- Python support is a release gate, not a best effort: the existing `python-e2e` CI job stays unfiltered on every non-draft PR and is retargeted to the ported host (including its pinned Python-side checkout and toolchain). New CI work is a wheel build plus smoke test. +- Parity excludes pii and audit flows: 0.3.0 removes those builtins, so their snapshots would only become allow-list entries. The removal is documented as a break instead. +- Implementation must not leak plan or requirement IDs into source, comments or docs. + +--- + +## Open Questions + +### Resolved During Planning + +- C ABI renames (origin deferred): renamed, ABI version bumped. +- Python host and FFI public-API gaps (origin deferred): `PolicyConfig`, `PolicyEngineConfig` and the hook names differ; the first two exist publicly in PPE 0.4.1, and the hook names change behavior (see Key Technical Decisions). +- Distinct JWT egress code: not required for 0.3.0. `auth.jwks_unavailable` is documented as the symptom of a blocked private JWKS. Adding `auth.egress_denied` upstream is a follow-up. +- Parity mechanism (origin deferred): golden snapshots from 0.2.3. +- Private-destination egress: PPE default with per-binding opt-in (user decision). +- Removed crate names on crates.io (origin deferred): left at 0.2.3, noted in changelog. + +### Deferred to Implementation + +- Exact representative parity config set: pick during U1 from tutorial modules, go-demo and binding fixtures, covering identity, delegation, each PDP, session labels and elicitation. +- Which PPE e2e flows already cover what the CPEX acceptance suite would test: check `praxis-policy:crates/ppe-core/tests/` when writing U10 and only add CPEX-surface coverage. +- Which PPE doc pages are adopted, adapted or linked: decide per page in U12. +- Whether new native link flags are needed for Go after the transport change: discovered when U7 links on macOS and Linux. +- Whether the CEL resolver delta is already in PPE: determined in U2. +- Whether the out-of-process Python worker SDK (outside this repo, pointed to by `CPEX_PYTHON_SOURCE`) registers handlers by legacy hook names and needs its own release alongside 0.3.0: determined in U4. + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +Unit dependency graph: + +```mermaid +graph TD + U1[U1 Golden snapshots from 0.2.3] --> U3 + U2[U2 Post-port delta audit] --> U3 + U3[U3 Workspace cut-over + facade] --> U4[U4 Python host] + U3 --> U5[U5 FFI] + U3 --> U6[U6 Biscuit] + U3 --> U13 + U5 --> U7[U7 Go package + go-demo] + U3 --> U8[U8 Python bindings] + U3 --> U9[U9 Tutorial] + U4 --> U10 + U7 --> U10[U10 Configs, fixtures, acceptance suites] + U8 --> U10 + U9 --> U10 + U10 --> U11[U11 Parity check] + U10 --> U12[U12 Docs + migration guide] + U11 --> U12 + U10 --> U15[U15 Edition 2024] + U11 --> U15 + U15 --> U13 + U13[U13 Makefile, CI, release] --> U14 + U12 --> U14[U14 0.3.0 release prep] +``` + +Surface layering after the cut-over: + +``` +praxis-policy (crates.io, exact 0.5.0 pin, features: builtins, http-hyper) + └─ cpex (facade: re-exports PPE + CPEX-only features) + ├─ cpex-hosts-python (isolated_venv factory) + ├─ delegator-biscuit (CPEX-owned plugin) + ├─ cpex-ffi (C ABI v3, installs builtins + transport + Vault) + │ └─ go/cpex (cgo wrapper, PolicyEngine) + ├─ bindings/python (PolicyEngine, PluginManager alias) + └─ examples/tutorial, examples/go-demo +``` + +--- + +## Implementation Units + +### Phase A: Baseline + +- U1. **Capture 0.2.3 golden decision snapshots** + +**Goal:** Record what 0.2.3 decides for a representative config and request set, before any crate is removed. + +**Requirements:** R21; AE5 + +**Dependencies:** None + +**Files:** +- Create: `tests/parity/` fixtures (configs in 0.2.3 form, request sets, recorded outcomes) +- Create: a small capture harness built against the current workspace; retain its sources and lockfile under `tests/parity/baseline/` when U11 retargets the replay harness. The out-of-tree runner pins the immutable 0.2.3 engine commit. +- Test: `tests/parity/README.md` documents how snapshots were produced and from which commit + +**Approach:** +- Pick configs covering identity (jwt, oauth delegation), each PDP (cedar, cel, opa), Valkey session labels, and elicitation. Leave out pii and audit flows, since 0.3.0 removes those builtins. +- Record each request with its hook name and payload, keyed so U11 can map it through the hook rename table. Record per request: allow/deny, violation codes, plugin effects (modified payload, labels, headers). Store in a stable, diffable format. +- Export the raw Valkey `taint:v1` keyspace written during capture into `tests/parity/` so U11 can seed a store from it. +- Exclude anything non-deterministic (timestamps, generated IDs) at capture time, and scrub tokens and keys from recorded effects; fixtures use env or file secret sources, never literals. + +**Execution note:** Characterization-first. This unit must land before U3. + +**Test scenarios:** +- Happy path: running the harness twice on 0.2.3 produces byte-identical snapshots and keyspace exports. +- Edge case: a config whose request set includes a deny path records the violation code, not only the deny bit. + +**Verification:** +- Snapshots exist for every chosen config and are reproducible from the recorded commit. + +--- + +- U2. **Audit post-port CPEX deltas against PPE** + +**Goal:** Ensure no CPEX fix made after the port is lost by deleting CPEX's copy. + +**Requirements:** R2 (supports), origin key decision on upstreaming + +**Dependencies:** None + +**Files:** +- Review: `builtins/pdps/cel/src/resolver.rs`, `builtins/plugins/identity-jwt/`, `crates/cpex/src/lib.rs` diffs since `aed0f15` +- Compare: `praxis-policy:crates/builtins/src/pdps/cel/`, `praxis-policy:crates/builtins/src/plugins/identity_jwt/` (or equivalent paths) + +**Approach:** +- For each delta, decide: already in PPE, not needed in PPE, or needs upstreaming. Upstream items go to PPE as their own PRs; record any that require a PPE release before the 0.3.0 tag. + +**Test expectation:** none -- audit unit; any upstream fix carries its own tests in PPE. + +**Verification:** +- Every post-port core/builtin delta has a recorded disposition, and no CPEX-only fix is pending without an upstream PR. + +--- + +### Phase B: Cut-over and surface ports + +- U3. **Workspace cut-over and facade re-export** + +**Goal:** Replace the CPEX engine crates with PPE 0.4.x and make `cpex` a facade over PPE. + +**Requirements:** R1, R2, R3, R6, R8 + +**Dependencies:** U1, U2 + +**Files:** +- Modify: `Cargo.toml` (members, default-members, `[workspace.dependencies]`, workspace lints with `unexpected_cfgs` denied) +- Modify: `Cargo.lock`, `deny.toml` (comments naming removed crates) +- Delete: `crates/cpex-core`, `crates/cpex-orchestration`, `crates/cpex-sdk`, `crates/cpex-builtins`, `crates/apl-core`, `crates/apl-cmf`, `crates/apl-cpex`, `builtins/plugins/{pii-scanner,audit-logger,identity-jwt,delegator-oauth,elicitation-ciba}`, `builtins/pdps/{cedar-direct,cel,opa}`, `builtins/session/valkey` +- Modify: `crates/cpex/Cargo.toml`, `crates/cpex/src/lib.rs`, `crates/README.md` +- Modify (manifests only): `crates/cpex-hosts-python/Cargo.toml`, `crates/cpex-ffi/Cargo.toml`, `builtins/plugins/delegator-biscuit/Cargo.toml`, `bindings/python/Cargo.toml`, `examples/tutorial/Cargo.toml`, `examples/go-demo/ffi/Cargo.toml` +- Test: `crates/cpex/src/lib.rs` inline tests, new `crates/cpex/tests/facade.rs` + +**Approach:** +- `cpex` depends on `praxis-policy` (exact pin) with `builtins` and `http-hyper`, re-exports its public surface (including `prelude`, `PolicyEngine`, install helpers, factories) and keeps the CPEX-only `python-host` feature. +- Repoint every kept crate's manifest to the crates.io PPE crates in this same commit so the workspace and `Cargo.lock` resolve; source changes stay in U4-U9. Remove `pii`, `audit` and old per-builtin features, or map them to PPE features where the name still means something; drop the ones that no longer do. +- Remove features, deps, re-exports, registration sites and factory-count assertions for removed builtins together. +- Port the facade inline tests to `PolicyConfig` and `cmf.*` hook names. + +**Patterns to follow:** +- `praxis-policy:crates/ppe/src/lib.rs` re-export layout. + +**Test scenarios:** +- Happy path: `install_builtins` on a new `PolicyEngine` registers no pii or audit kind (the full kind list is asserted once, in U5's wire-compat test). +- Happy path: a config using `cmf.tool_pre_invoke` loads and dispatches through the facade. +- Error path: a config with a stale `cfg(feature)` gate fails the build (verified by the workspace lint, not a runtime test). + +**Verification:** +- The workspace resolves; `cargo build -p cpex` and `cargo test -p cpex` without `python-host` pass against crates.io PPE with no path or git dep on PPE; removed directories are gone. +- Record before/after executed-test counts and every ignored test, including doctests, in [test reconciliation](ppe-adoption-test-reconciliation.md). Map every deleted suite to its PPE owner or explicitly document intentional removal. A dependency's tests do not run as part of the CPEX workspace test command. + +--- + +- U4. **Port the Python plugin host** + +**Goal:** `isolated_venv` plugins run on `PolicyEngine`. + +**Requirements:** R5, R19, R25 + +**Dependencies:** U3 + +**Files:** +- Modify: `crates/cpex-hosts-python/Cargo.toml`, `crates/cpex-hosts-python/src/**` (notably `factory.rs`, `plugin.rs`, `testing.rs`) +- Modify: `crates/cpex-hosts-python/README.md`, `crates/cpex-hosts-python/quickstart.md` +- Test: `crates/cpex-hosts-python/tests/{config_e2e,credential_e2e,extensions_merge_e2e,isolated_venv_e2e}.rs` + +**Approach:** +- Swap imports to `praxis_policy_core`. Replace `CpexConfig` and `ManagerConfig` with `PolicyConfig` and `PolicyEngineConfig`. +- Remap payload-kind and credential-hook detection onto PPE's dispatched names (`cmf.*`, `identity.resolve`, `token.delegate`), so typed tool, prompt and resource payloads and raw credentials still reach Python plugins. +- Check whether the Python worker SDK registers handlers by legacy names; if it does, coordinate its release with 0.3.0. +- Remove the top-level `plugin_dirs` from fixtures (PPE rejects it); keep the in-plugin `config:` warning logic. + +**Test scenarios:** +- Happy path: an `isolated_venv` plugin registered on `PolicyEngine` handles `cmf.tool_pre_invoke` and returns its modified payload. +- Integration: credential and extension-merge e2e tests pass under `CPEX_PYTHON_SOURCE`. +- Error path: a config with top-level `plugin_dirs` fails the load with PPE's unknown-key error. +- Integration: a Python identity plugin on `identity.resolve` and a delegation plugin on `token.delegate` receive the raw credential. +- Edge case: a hook not in the remap table falls back to the generic payload and receives no raw credential. +- Integration: an `isolated_venv` plugin that raises a violation produces the same violation code and deny result through `PolicyEngine` as on 0.2.3 (cross-checked in U11). +- Integration: the `python-host` facade feature registers `isolated_venv` on a `PolicyEngine` built through the facade. + +**Verification:** +- Unit tests and the ignored e2e suite pass on the ported crate, and the `python-host` feature builds through the facade. + +--- + +- U5. **Port the C FFI to ABI v3** + +**Goal:** The FFI wraps `PolicyEngine`, installs builtins, the transport and Vault, and exposes a private-destination opt-in. + +**Requirements:** R5, R6, R9, R10, R11; AE4 + +**Dependencies:** U3 + +**Files:** +- Modify: `crates/cpex-ffi/Cargo.toml`, `crates/cpex-ffi/src/lib.rs`, `crates/cpex-ffi/src/apl.rs` +- Test: `crates/cpex-ffi/src/lib.rs` tests, new `crates/cpex-ffi/tests/wire_compat.rs` + +**Approach:** +- Rename `cpex_manager_*` to `cpex_engine_*` and the inner struct to match; bump `FFI_ABI_VERSION` to 3. +- `cpex_apl_install` takes a flags argument (bit for private destinations). It calls PPE `install_builtins`, builds one `HyperTransport`, sets it on the engine and passes it to `registry_with_vault`. A per-handle installed flag makes a second call a no-op. The one-shot constructor stays builtin-free and transport-free. +- Add a secret-refresh entry point returning the refresh report and a last-success query. +- Replace the APL test's `audit/logger` usage with a shipped builtin. + +**Test scenarios:** +- Happy path: the ABI version call returns 3. +- Covers AE4. Integration: an identity-jwt plugin with `jwks_url` validates a token against a local JWKS server when the private-destination opt-in is on. +- Error path: same config with the opt-in off logs a soft-fail warning at initialize and reports `auth.jwks_unavailable` at request time. +- Error path: an OAuth delegator pointed at a private token endpoint with the opt-in off reports `delegation.egress_denied`. +- Edge case: calling the install entry point twice is a no-op; a later config load yields the same route and plugin counts as a single install. +- Happy path: after a Vault secret is rotated in a test Vault, calling refresh makes the new value visible, and the last-success query reports the refresh time. +- Happy path: the wire-compat test asserts the set of registered `kind` strings and hook names matches the pinned list (no pii/audit, includes opa, ciba, api-key). + +**Verification:** +- FFI tests pass; the static and dynamic libs build on the release target matrix. + +--- + +- U6. **Port delegator-biscuit** + +**Goal:** Biscuit remains a CPEX-owned plugin on PPE's plugin API. + +**Requirements:** R4 + +**Dependencies:** U3 + +**Files:** +- Modify: `builtins/plugins/delegator-biscuit/Cargo.toml`, `builtins/plugins/delegator-biscuit/src/**` +- Test: `builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs` + +**Approach:** +- Swap imports to `praxis_policy_core` delegation, plugin and raw-credential modules; tests use `PolicyEngine`. The facade does not expose biscuit, matching 0.2.3. + +**Test scenarios:** +- Happy path: a delegate step with biscuit attenuation produces an attenuated token whose caveats match the config. +- Error path: an invalid root key fails plugin initialization with a clear error. + +**Verification:** +- Biscuit e2e passes against PPE. + +--- + +- U7. **Port the Go package and go-demo** + +**Goal:** Go integrators use `PolicyEngine` over ABI v3, with the private-destination option. + +**Requirements:** R6, R8, R9, R11; AE1, AE4 + +**Dependencies:** U5 + +**Files:** +- Modify: `go/cpex/{manager,apl,cmf,identity,ffi,abi,types,constants,errors}.go` (rename `manager.go` if it helps readability), `go/cpex/README.md` +- Modify: `examples/go-demo/**` (ffi crate, `go.mod` pin to 0.3.0, YAML configs) +- Test: `go/cpex/{manager_test,apl_test,cmf_test}.go` + +**Approach:** +- Rename Go API (`NewPolicyEngine`, `NewPolicyEngineDefault`, `PolicyEngine`), update cgo prototypes to `cpex_engine_*`, check ABI v3 at init, add an option for private destinations that maps to the install flags, and add `RefreshSecrets` plus a last-success query. +- Go tests are not in CI today; U13 adds them. +- Re-verify link flags on macOS and Linux; add frameworks or libs if the new dependency graph needs them. +- go-demo ffi uses the renamed inner struct; its configs are ported in U10. + +**Test scenarios:** +- Happy path: create engine, load config, invoke `cmf.tool_pre_invoke`, shut down. +- Error path: a library built at ABI v2 is rejected with an ABI mismatch error. +- Integration: `go test -race` passes against the static library on macOS and Linux. + +**Verification:** +- Go tests and race tests pass; go-demo builds and runs. + +--- + +- U8. **Port the Python bindings** + +**Goal:** The wheel exposes `PolicyEngine`, keeps the `PluginManager` alias, and ships the new builtin set with the transport opt-in. + +**Requirements:** R6, R7, R9, R10, R11, R25; AE1, AE3 + +**Dependencies:** U3 + +**Files:** +- Modify: `bindings/python/Cargo.toml`, `bindings/python/src/{lib,manager,result,builtins}.rs` +- Modify: `bindings/python/python/cpex/__init__.py`, `bindings/python/python/cpex/_lib.pyi`, `bindings/python/pyproject.toml` (version) +- Test: `bindings/python/tests/*.py`, fixtures under `bindings/python/tests/` + +**Approach:** +- Import `engine::PolicyEngine` and `executor::PipelineResult` from their modules. Rename the pyclass to `PolicyEngine`; `__init__.py` adds `PluginManager = PolicyEngine`; stubs and `__all__` list both. +- Builtin registration delegates to PPE `install_builtins`, plus the shared transport and Vault, matching the FFI, and is idempotent. Add `refresh_secrets` and a last-success accessor. Add a constructor keyword argument for private destinations; no surface reads this switch from the policy document. + +**Test scenarios:** +- Covers AE1. Happy path: `from cpex import PluginManager` and `from cpex import PolicyEngine` refer to the same class; both load and run a migrated config. +- Covers AE3. Error path: a config declaring `validator/pii-scan` fails with an unknown-kind error. +- Happy path: OPA and CIBA kinds load in a config (CIBA with a mocked endpoint and the opt-in on). +- Integration: a freshly built wheel installs into a clean venv, imports `cpex`, and runs a policy (smoke test used by CI). +- Happy path: the registered kind and hook names match the shared wire-compat fixture. +- Error path: invoking a hook from Python after `shutdown` raises a Python exception rather than crashing the interpreter. + +**Verification:** +- Binding tests pass via the Makefile binding targets; stubs type-check; the wheel smoke test passes. + +--- + +- U9. **Port the tutorial** + +**Goal:** The tutorial builds and passes its checks on PPE. + +**Requirements:** R6, R13, R19 + +**Dependencies:** U3 + +**Files:** +- Modify: `examples/tutorial/Cargo.toml`, `examples/tutorial/src/**`, `examples/tutorial/tests/policy_tests.rs`, `examples/tutorial/README.md` + +**Approach:** +- Replace the old SDK crate imports with `prelude`, `PluginManager` with `PolicyEngine`. The tutorial's HTTP transport turns on private destinations (JWKS on localhost), with a comment that this is not a production setting. Module YAML is ported in U10. + +**Test scenarios:** +- Integration: the local tutorial check passes; the Keycloak and SPIRE variants pass in CI. + +**Verification:** +- `examples/tutorial` builds; policy tests pass. + +--- + +### Phase C: Configs, verification and docs + +- U10. **Migrate configs and fixtures; add acceptance suites** + +**Goal:** Every shipped config loads under PPE, and each CPEX surface has acceptance coverage for key flows. + +**Requirements:** R12, R13, R19, R20; AE2, AE3 + +**Dependencies:** U4, U7, U8, U9 + +**Files:** +- Modify: `examples/go-demo/*.yaml`, `examples/tutorial/policies/**`, tutorial module YAML (m04, m05, m18, capstone, capstone-nodeleg), `go/cpex/manager_test.go` fixtures, `bindings/python/tests/` fixtures (including `pii_deny.yaml`, `pii_redact.yaml`), `crates/cpex-hosts-python` fixtures +- Create: `bindings/python/tests/test_acceptance.py`, `go/cpex/acceptance_test.go`, `crates/cpex-hosts-python/tests/acceptance_e2e.rs` + +**Approach:** +- Rewrite stale keys (`plugin_settings`, `global.policies`, route `plugins:` lists, `priority:` outside hooks mode, `apl:` wrapper, top-level `plugin_dirs`) and legacy hook names to PPE shape; declare `perform_http` on every plugin using `jwks_url`, OAuth delegation or CIBA; resolve unreached-plugin load failures. +- pii/audit fixtures in Python and Go switch to shipped builtins. Tutorial modules using pii/audit (m04 and the capstones) switch to shipped builtins; the docs, not the tutorial, show Rust-host registration of the reference plugins. +- Acceptance flows per surface: config load (including a removed-key failure), identity, delegation, each PDP decision, session labels, elicitation where practical. Skip flows already proven by PPE's own e2e suites unless the CPEX surface adds risk. +- Record each config rewrite pattern for the migration guide (U12). + +**Test scenarios:** +- Covers AE2. Error path: a 0.2.x config with `global.policies:` fails with an unknown-key error naming the key, through Python, Go and Rust. +- Happy path: each migrated example config loads with no unreached-plugin error. +- Integration: Python and Go acceptance suites drive identity, a cedar decision and an opa decision end to end through the native library. +- Integration: the Python host acceptance test runs an `isolated_venv` plugin in the identity and PDP flows through `PolicyEngine`. +- Error path: a config using `jwks_url` without `perform_http` fails to start with PPE's capability error. +- Integration: session labels persist across two engines sharing a Valkey store (ignored test, run in CI with a service container). + +**Verification:** +- All shipped configs load; acceptance suites pass on the Python bindings, Go/FFI and Python host surfaces. + +--- + +- U11. **Parity check against 0.2.3 snapshots** + +**Goal:** Prove 0.3.0 decisions match 0.2.3, or document every difference. + +**Requirements:** R21; AE5 + +**Dependencies:** U1, U10 + +**Files:** +- Modify: `tests/parity/` (add migrated configs alongside 0.2.3 forms, and the reviewed legacy-to-PPE hook rename table) +- Create: a parity test that replays request sets through the 0.3.0 facade and diffs against the U1 snapshots (location chosen during implementation) + +**Approach:** +- Requests replay through the hook rename table, and a store seeded from the U1 Valkey export is loaded before session-label replays. +- Assert exactly one row for every captured hook name and assert PPE target coverage against its builtin registry, with any unused names listed explicitly. Check skipped fixtures too; reject duplicate keys and incorrect removed-hook replacements. +- Each difference is either fixed (CPEX port bug or upstream PPE fix) or listed as intended with its cause, feeding the migration guide. + +**Test scenarios:** +- Covers AE5. Happy path: for each config, decisions and violation codes match the snapshot. +- Integration: session labels written by 0.2.3 (from the keyspace export) are read back unchanged by 0.3.0. +- Edge case: a known intended difference (for example a load-time failure PPE adds) is asserted against an allow-list entry, so it cannot drift silently. + +**Verification:** +- Parity test passes with an explicit, reviewed list of intended differences. + +--- + +- U12. **Docs, migration guide and changelog content** + +**Goal:** CPEX docs reflect 0.3.0 and include current PPE material. + +**Requirements:** R14, R15, R16, R17, R18 + +**Dependencies:** U10, U11 + +**Files:** +- Modify: `README.md`, `docs/content/_index.md`, `docs/content/docs/*.md` (overview, quickstart, configuration, pipeline, builtins, extensions, identity-delegation, patterns, deployment, testing, threat-model, reference), `docs/content/docs/apl/**`, `docs/content/docs/tutorial/**`, `docs/specs/**`, `docs/operations/valkey-session-store.md`, `go/cpex/README.md`, `crates/README.md` +- Create: `docs/content/docs/migration-0.3.md`, and new pages adapted from PPE (HTTP routing, assertions, auditing, identity claim mapping, CMF extensions, crate map) + +**Approach:** +- Rename across docs; update every config snippet to PPE shape; document new builtins in bindings. +- Document HTTP transport and the private-destination opt-in per binding. State that the switch also permits loopback, RFC 1918, link-local (including cloud metadata) and CGNAT for every outbound builtin, and that tutorial and demo settings are not for production. +- Document Rust-host registration of PPE's reference plugins by copying their source into the embedder's own workspace, built against crates.io `praxis-policy-core`, and state they are absent from bindings. +- Document that binding hosts using Vault must call secret refresh on an interval and alarm on the last-success value. +- Update the threat-model page for the new binding builtins (Vault auth and secret handling, CIBA, api-key directory, OPA), the bundled transport and its private-destination default, and the secret-refresh responsibility of binding hosts. +- Migration guide sections: API renames (Rust, Python, Go, C ABI), hook renames (for config, Python plugin authors and Go `InvokeByName` callers), config keys (from U10 notes and `REPLACED_KEYS`), new required capabilities (`perform_http`), dispatch default and load-time failures, egress default (including that a blocked private JWKS shows as `auth.jwks_unavailable`), builtins added/removed, intended parity differences (from U11). +- Fix links into deleted paths and renamed Praxis paths; keep `0.1.x` docs as legacy. + +**Test scenarios:** +- Test expectation: none -- docs; correctness is enforced by the docs build and link check in CI. + +**Verification:** +- Docs site builds; link check passes; no `PluginManager` reference outside the Python alias note and the migration guide. + +--- + +### Phase D: Build, CI and release + +- U13. **Makefile, CI and release workflows** + +**Goal:** Build, test and release machinery match the new layout. + +**Requirements:** R22, R19, R25 + +**Dependencies:** U3 (can progress alongside U4-U12; finalize after U15) + +**Files:** +- Modify: `Makefile` (`examples-run`, `publish-dry`, `tag`, `release*`, `go-*`, `bindings-python-*`, `tutorial-check*`, new acceptance and parity targets) +- Modify: `.github/workflows/ci.yml`, `.github/workflows/release.yaml`, `.github/workflows/release-ffi.yaml`, `.github/workflows/tutorial.yaml`, `.github/workflows/coverage.yaml`, `.github/workflows/supply-chain.yaml`, `.github/workflows/docs-build.yaml` if paths change +- Modify: `release.toml`, `scripts/release/build-artifact.sh`, `scripts/download-ffi-artifact.sh` if names change + +**Approach:** +- Rewrite the publish order to the kept publishable crates in dependency order (python host, biscuit, facade, cpex-python, any other kept publishable crate); keep the idempotent skip; update `cargo package --exclude` lists. +- Remove `examples-run` targets that ran removed crates' examples; repoint to facade examples or drop. +- Wire acceptance and parity into CI; run the Go race job on macOS and Linux. +- Keep the existing `python-e2e` job unfiltered and retarget it to the ported host (pinned Python-side checkout, toolchain). Add a wheel build plus smoke test job. +- Add Go test and race jobs (not in CI today) on macOS and Linux. +- Review `deny.toml` and supply-chain checks for the new transport and TLS dependencies pulled in by `http-hyper`. +- Update tutorial path triggers to the kept directories. + +**Test scenarios:** +- Test expectation: none -- build configuration; verified by a green CI run and a release dry run. + +**Verification:** +- Full CI passes on a PR branch; release dry run packages every publishable crate in order. + +--- + +- U14. **0.3.0 release prep** + +**Goal:** Version, changelog and tags ready for 0.3.0. + +**Requirements:** R18, R23, R24 + +**Dependencies:** U12, U13, and any PPE release gated by U2 + +**Files:** +- Modify: `CHANGELOG.md`, workspace version, `bindings/python/pyproject.toml`, `examples/go-demo/go.mod`, `ADOPTERS.md`/`README.md` version mentions if any + +**Approach:** +- Changelog lists every break across Rust, Python, Go, C ABI and config, links the migration guide, and notes removed crates stay at 0.2.3 on crates.io. + +**Test scenarios:** +- Test expectation: none -- release metadata. + +**Verification:** +- Release dry run succeeds; all commits on the branch are signed off with short conventional messages and no AI trailers. + +--- + +- U15. **Move CPEX to edition 2024** + +**Goal:** CPEX uses the same edition and resolver as PPE. + +**Requirements:** R26 + +**Dependencies:** U4, U5, U6, U7, U8, U9, U10, U11 + +**Files:** +- Modify: `Cargo.toml` (workspace `edition`, `resolver = "3"`), every kept crate's `Cargo.toml`, `clippy.toml`, `rustfmt.toml` (style edition), `rust-toolchain.toml` if PPE's pin moves +- Modify: source in `crates/cpex`, `crates/cpex-hosts-python`, `crates/cpex-ffi`, `builtins/plugins/delegator-biscuit`, `bindings/python`, `examples/go-demo/ffi`, `examples/tutorial` as the edition migration requires + +**Approach:** +- Mechanical edition migration on the already-ported crates, then manual review of the semantic changes edition 2024 brings: `unsafe extern` blocks and `unsafe` attributes such as `no_mangle` (heavy in the FFI), `unsafe_op_in_unsafe_fn`, `gen` keyword, RPIT lifetime capture, tail-expression temporary scope, `if let` rescoping, and `std::env::set_var` becoming unsafe in tests. +- Land as its own commit series, separate from behavior changes, so review can confirm no semantic drift. + +**Patterns to follow:** +- PPE's own edition move and lint settings: `praxis-policy:Cargo.toml`, `praxis-policy:clippy.toml`, `praxis-policy:docs/dev/lints.md`. + +**Test scenarios:** +- Test expectation: none new -- behavior-neutral; the full existing suite plus acceptance and parity (U10, U11) must stay green before and after. + +**Verification:** +- Workspace builds on edition 2024 with resolver 3; clippy and fmt clean; FFI, Go, Python and parity suites unchanged in results. + +--- + +## System-Wide Impact + +- **Interaction graph:** Every host entry point changes: Rust facade, FFI and Go, PyO3, Python host. The Go package depends on the exact C ABI; the go-demo depends on the FFI's public inner struct. +- **Error propagation:** Egress refusal surfaces as `egress_denied` for oauth and CIBA, and as `auth.jwks_unavailable` for JWT; config errors (stale keys, legacy hook names, missing `perform_http`) surface at load, not at request time. +- **State lifecycle risks:** The transport is set-once per engine; double install must stay a no-op. Valkey session store keys and format come from PPE; the parity check should confirm 0.2.3 stored labels are still readable. +- **API surface parity:** Rename and builtin set must match across Rust, Python, Go and the C ABI; the wire-compat test guards kinds and hook names. +- **Integration coverage:** Python wheel smoke test and `isolated_venv` e2e as CI gates; Go race and static link on macOS and Linux; Python e2e host; Keycloak and SPIRE tutorial variants. +- **Unchanged invariants:** Plugin `kind` strings, APL syntax and violation codes stayed wire-compatible. Hook names did not: legacy unprefixed names are removed, `prompt_*_fetch` became `cmf.prompt_*_invoke`, and identity and delegation use `identity.resolve` and `token.delegate`. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| PPE lacks something a kept crate needs | Fixable gaps become separately reviewed PPE releases, gating the 0.3.0 tag; unfixable or unexplained gaps trigger the abort criterion below | +| A needed PPE fix ships alongside unrelated breaking changes | Review the release changes and port affected surfaces; rerun U11 whenever the exact pin moves | +| Python plugins silently lose credentials after the hook rename | Remap in U4 with tests that raw credentials arrive on `identity.resolve` and `token.delegate` | +| The external Python worker SDK dispatches only legacy hooks | The in-repo legacy payload shim preserves 0.1.x workers; gate merge and the 0.3.0 tag on the four `make test-python-e2e` suites (credential, isolated-venv, extensions-merge and acceptance) against the supported `CPEX_PYTHON_SOURCE` checkout with no skips. If that compatibility path fails and requires an SDK change, the CPEX release maintainer coordinates and verifies the SDK release before tagging 0.3.0 | +| Session store format drift breaks existing Valkey data | Include a stored-label read in parity snapshots; document if incompatible | +| New native link deps break Go builds on one platform | Run Go race on macOS and Linux in CI from U7 onward | +| Egress default breaks integrators silently | Distinct codes for oauth and CIBA; JWT shows `auth.jwks_unavailable`, called out in the migration guide; opt-in documented per binding | +| Vault secrets go stale in bindings | Refresh entry point on every surface; docs require periodic refresh | +| Stale `cfg(feature)` predicates drop exports silently | Deny `unexpected_cfgs` in U3 | +| Publish order wrong for the new crate set | Release dry run in CI; idempotent skip allows a rerun | +| Docs link CI fails on deleted paths | Link fixes are part of U12, not cleanup | +| Edition 2024 changes FFI `unsafe` semantics or drop order | Separate commit series after ports; parity and FFI suites rerun before and after | +| Python wheel breaks without notice (no wheel CI today) | Wheel build and smoke test job (U13) | + +**Abort criterion:** If U11 finds a behavior difference that cannot be fixed or explicitly accepted with its cause and migration impact by the CPEX release maintainer, stop the cut-over and leave the integration branch unmerged and 0.3.0 untagged; the release maintainer decides whether to resume after a fix or abandon the branch. + +--- + +## Documentation / Operational Notes + +- Migration guide is the primary upgrade path for adopters; announce it with the release. +- Go consumers must update both the module and the FFI artifact together (ABI v3). + +--- + +## Sources & References + +- **Origin document:** [docs/brainstorms/ppe-core-adoption-requirements.md](../brainstorms/ppe-core-adoption-requirements.md) +- Prior plan: [docs/plans/2026-08-08-001-feat-praxis-policy-engine-port-plan.md](2026-08-08-001-feat-praxis-policy-engine-port-plan.md) +- PPE: `praxis-policy:CHANGELOG.md`, `praxis-policy:docs/dev/port-provenance.md`, `praxis-policy:docs/content/upgrade-apl.md`, `praxis-policy:crates/ppe/src/lib.rs` diff --git a/docs/plans/ppe-adoption-follow-ups.md b/docs/plans/ppe-adoption-follow-ups.md new file mode 100644 index 00000000..2226bc97 --- /dev/null +++ b/docs/plans/ppe-adoption-follow-ups.md @@ -0,0 +1,30 @@ +# PPE adoption follow-ups + +These follow-ups come from [Teryl's review of CPEX #190](https://github.com/contextforge-org/cpex/pull/190). Both were confirmed against the published PPE 0.4.1 and released PPE 0.5.0 sources. The fixes will land in a separate PPE PR; CPEX now pins 0.5.0 and will adopt a later release after parity passes. + +## [PPE #208](https://github.com/praxis-proxy/policy/issues/208): fix(apl): make install_builtins idempotent per engine + +In PPE 0.5.0, `install_builtins` calls `register_apl` on every invocation and discards the returned visitor handle ([PPE facade](https://github.com/praxis-proxy/policy/blob/v0.5.0/crates/ppe/src/lib.rs#L279-L286)). `PolicyEngine::register_visitor` appends unconditionally ([engine visitor registration](https://github.com/praxis-proxy/policy/blob/v0.5.0/crates/ppe-core/src/engine.rs#L1497-L1502)). Calling the helper twice therefore installs two APL visitors on the same engine. + +Reproducer: + +```rust +let engine = std::sync::Arc::new(praxis_policy::PolicyEngine::default()); +praxis_policy::install_builtins(&engine); +praxis_policy::install_builtins(&engine); +// Loading policy sections now walks two APL visitors. +``` + +Make `install_builtins` idempotent per engine, or reject duplicate APL visitor registration without suppressing unrelated custom visitors. Add a regression test covering two installs, a subsequent policy load, and dispatch with one execution of each configured step. Also cover concurrent installs and an interrupted install if the implementation introduces install state. + +CPEX 0.3.0 retains its FFI and Python per-engine guards as defence in depth. Rust embedders must install builtins once until PPE fixes this. If CPEX adopts the fix, land it in a separate PPE PR, release the fix, move the exact dependency pin, and rerun parity before tagging 0.3.0. + +## [PPE #209](https://github.com/praxis-proxy/policy/issues/209): feat(http): allow specific private destinations without opening all private networks + +PPE 0.5.0 `HyperTransport` exposes only `with_allow_private_destinations` ([transport source](https://github.com/praxis-proxy/policy/blob/v0.5.0/crates/ppe/src/http_hyper.rs#L216)). It permits private destinations globally for every builtin sharing the transport, including JWKS, OAuth delegation, CIBA and Vault. + +A host needing JWKS from localhost must therefore also permit RFC 1918, link-local addresses including cloud metadata, and CGNAT. Add a host-configured allow-list of private hosts or destinations that keeps all other private destinations blocked. Policy documents must not be able to widen that list. Preserve the default denial and check resolved addresses, redirects and IPv4/IPv6 consistently with the existing transport checks. + +Acceptance: a permitted localhost JWKS succeeds, another private host and `169.254.169.254` remain blocked, and redirects or DNS resolution cannot escape the allow-list. Include Vault because it shares the host transport. + +This is deferred PPE feature work, not a CPEX 0.3.0 release dependency. Meanwhile CPEX's binding startup warns whenever its global opt-in is enabled; Rust hosts can install their own transport with narrower egress rules. diff --git a/docs/plans/ppe-adoption-test-counts.json b/docs/plans/ppe-adoption-test-counts.json new file mode 100644 index 00000000..5df3962d --- /dev/null +++ b/docs/plans/ppe-adoption-test-counts.json @@ -0,0 +1,1282 @@ +{ + "date": "2026-10-13", + "environment": "macOS; rustc 1.96.0; default workspace features; Valkey 8 for after parity", + "before": { + "revision": "e3a2b30bbaf14682808f4e1de4e3e5fa5be9f481 (v0.2.3)", + "command": "cargo test --workspace --locked --offline", + "executed": 1434, + "ignored": 39, + "suites": [ + { + "package": "apl-cmf", + "suite": "lib", + "executed": 56, + "ignored": 0 + }, + { + "package": "apl-cmf", + "suite": "tests/end_to_end.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "apl-core", + "suite": "lib", + "executed": 299, + "ignored": 0 + }, + { + "package": "apl-core", + "suite": "tests/yaml_end_to_end.rs", + "executed": 8, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "lib", + "executed": 106, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/attribute_source_e2e.rs", + "executed": 11, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/canonical_authn_authz_e2e.rs", + "executed": 1, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/capability_gating.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/cmf_invoker_dispatch.rs", + "executed": 13, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/config_override.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/delegate_step_e2e.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/elicit_step_e2e.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/elicit_then_delegate_e2e.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/end_to_end_route.rs", + "executed": 13, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/global_http_authz.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/payload_mutation_propagation.rs", + "executed": 15, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/restrict_e2e.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "tests/visitor_e2e.rs", + "executed": 18, + "ignored": 0 + }, + { + "package": "cpex", + "suite": "lib", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-builtins", + "suite": "lib", + "executed": 3, + "ignored": 0 + }, + { + "package": "cpex-core", + "suite": "lib", + "executed": 344, + "ignored": 0 + }, + { + "package": "cpex-core", + "suite": "tests/delegation_e2e.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "cpex-core", + "suite": "tests/identity_e2e.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-core", + "suite": "tests/identity_route_e2e.rs", + "executed": 12, + "ignored": 0 + }, + { + "package": "cpex-demo-ffi", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "lib", + "executed": 10, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "lib", + "executed": 209, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "executed": 1, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "executed": 0, + "ignored": 5 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "executed": 9, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "executed": 0, + "ignored": 4 + }, + { + "package": "cpex-orchestration", + "suite": "lib", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-pdp-cedar-direct", + "suite": "lib", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-pdp-cedar-direct", + "suite": "tests/basic_allow_deny.rs", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-pdp-cedar-direct", + "suite": "tests/small_stack_eval.rs", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-pdp-cedar-direct", + "suite": "tests/visitor_pdp_config.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "cpex-pdp-cel", + "suite": "lib", + "executed": 26, + "ignored": 0 + }, + { + "package": "cpex-pdp-cel", + "suite": "tests/visitor_cel_config.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-pdp-opa", + "suite": "lib", + "executed": 68, + "ignored": 0 + }, + { + "package": "cpex-pdp-opa", + "suite": "tests/visitor_opa_config.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-plugin-audit-logger", + "suite": "lib", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "lib", + "executed": 4, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "tests/biscuit_e2e.rs", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-oauth", + "suite": "lib", + "executed": 13, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-oauth", + "suite": "tests/oauth_e2e.rs", + "executed": 14, + "ignored": 0 + }, + { + "package": "cpex-plugin-elicitation-ciba", + "suite": "lib", + "executed": 8, + "ignored": 0 + }, + { + "package": "cpex-plugin-elicitation-ciba", + "suite": "tests/ciba_e2e.rs", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-plugin-elicitation-ciba", + "suite": "tests/live_keycloak.rs", + "executed": 0, + "ignored": 1 + }, + { + "package": "cpex-plugin-identity-jwt", + "suite": "lib", + "executed": 23, + "ignored": 0 + }, + { + "package": "cpex-plugin-identity-jwt", + "suite": "tests/jwks_url_e2e.rs", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-plugin-identity-jwt", + "suite": "tests/jwt_e2e.rs", + "executed": 10, + "ignored": 0 + }, + { + "package": "cpex-plugin-pii-scanner", + "suite": "lib", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-python", + "suite": "lib", + "executed": 2, + "ignored": 0 + }, + { + "package": "cpex-sdk", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-session-valkey", + "suite": "lib", + "executed": 13, + "ignored": 0 + }, + { + "package": "cpex-session-valkey", + "suite": "tests/valkey_store_integration.rs", + "executed": 0, + "ignored": 5 + }, + { + "package": "cpex-tutorial", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "tests/policy_tests.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "apl-cmf", + "suite": "doctests", + "executed": 0, + "ignored": 1 + }, + { + "package": "apl-core", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "apl-cpex", + "suite": "doctests", + "executed": 0, + "ignored": 1 + }, + { + "package": "cpex", + "suite": "doctests", + "executed": 1, + "ignored": 2 + }, + { + "package": "cpex-builtins", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-core", + "suite": "doctests", + "executed": 7, + "ignored": 15 + }, + { + "package": "cpex-ffi", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-orchestration", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-pdp-cedar-direct", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-pdp-cel", + "suite": "doctests", + "executed": 0, + "ignored": 1 + }, + { + "package": "cpex-pdp-opa", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-audit-logger", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-oauth", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-elicitation-ciba", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-identity-jwt", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-pii-scanner", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-sdk", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-session-valkey", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "doctests", + "executed": 0, + "ignored": 0 + } + ], + "ignored_tests": [ + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "test": "config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin", + "reason": "requires cpex-test-plugin installed under plugins/ with a built venv" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "test": "config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin", + "reason": "requires cpex-test-plugin installed under plugins/ with a built venv" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_capable_plugin_reads_the_raw_token_end_to_end", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_declared_capability_that_cannot_be_honored_fails_closed_end_to_end", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_non_capable_plugin_on_the_same_hook_receives_no_token", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "the_credential_never_reaches_a_log_sink_the_host_controls", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "the_real_worker_answers_the_capabilities_handshake", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "test": "an_appended_label_is_dropped_without_the_capability", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "test": "an_appended_label_survives_the_merge_when_the_capability_is_declared", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_cmf_hook_is_rejected_cleanly_by_a_framework_that_has_no_cmf_hooks", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_real_plugin_deny_surfaces_as_a_deny_result", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_second_run_reuses_the_cached_venv", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "legacy_tool_pre_invoke_round_trips_through_the_real_worker", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-plugin-elicitation-ciba", + "suite": "tests/live_keycloak.rs", + "test": "live_dispatch_then_pending", + "reason": "requires a live Keycloak" + }, + { + "package": "cpex-session-valkey", + "suite": "tests/valkey_store_integration.rs", + "test": "cross_node_concurrent_append_unions", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-session-valkey", + "suite": "tests/valkey_store_integration.rs", + "test": "ttl_set_on_append_and_refreshed_on_load", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-session-valkey", + "suite": "tests/valkey_store_integration.rs", + "test": "unknown_session_returns_empty_ok", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-session-valkey", + "suite": "tests/valkey_store_integration.rs", + "test": "unreachable_endpoint_fails_closed", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-session-valkey", + "suite": "tests/valkey_store_integration.rs", + "test": "wrongtype_reply_fails_closed", + "reason": "marked #[ignore]" + }, + { + "package": "apl-cmf", + "suite": "doctests", + "test": "crates/apl-cmf/src/lib.rs - BagBuilder (line 84)", + "reason": "marked #[ignore]" + }, + { + "package": "apl-cpex", + "suite": "doctests", + "test": "crates/apl-cpex/src/register.rs - register::register_apl (line 133)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex", + "suite": "doctests", + "test": "crates/cpex/src/lib.rs - (line 46)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex", + "suite": "doctests", + "test": "crates/cpex/src/lib.rs - (line 75)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/delegation/hook.rs - delegation::hook::TokenDelegateHook (line 55)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/elicitation/hook.rs - elicitation::hook::ElicitationHook (line 50)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/extensions/container.rs - extensions::container::Extensions::cow_copy (line 162)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/factory.rs - factory::PluginFactory (line 39)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/factory.rs - factory::PluginFactoryRegistry (line 85)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/hooks/macros.rs - hooks::macros::define_hook (line 21)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/hooks/macros.rs - hooks::macros::define_hook (line 38)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/hooks/trait_def.rs - hooks::trait_def::HookHandler (line 102)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/identity/hook.rs - identity::hook::IdentityHook (line 65)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/manager.rs - manager::PluginManager::invoke_named (line 1086)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/manager.rs - manager::PluginManager::load_config_file (line 464)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/manager.rs - manager::PluginManager::register_factory (line 438)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/manager.rs - manager::PluginManager::register_handler (line 740)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/manager.rs - manager::PluginManager::register_handler_for_names (line 771)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-core", + "suite": "doctests", + "test": "crates/cpex-core/src/plugin.rs - plugin::Plugin (line 47)", + "reason": "marked #[ignore]" + }, + { + "package": "cpex-pdp-cel", + "suite": "doctests", + "test": "builtins/pdps/cel/src/resolver.rs - resolver::CelResolver::with_functions (line 152)", + "reason": "marked #[ignore]" + } + ] + }, + "after": { + "revision": "8b3cbb92ff25cfb1a8ea678f404646281c5965bd plus Teryl review fixes", + "command": "PARITY_VALKEY_URL=redis://127.0.0.1:6392/0 cargo test --workspace --offline -- --test-threads=4", + "executed": 408, + "ignored": 17, + "suites": [ + { + "package": "cpex", + "suite": "lib", + "executed": 10, + "ignored": 0 + }, + { + "package": "cpex", + "suite": "tests/facade.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "cpex-demo-ffi", + "suite": "lib", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "lib", + "executed": 19, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "tests/egress.rs", + "executed": 8, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "tests/wire_compat.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "lib", + "executed": 318, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/acceptance_e2e.rs", + "executed": 0, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "executed": 1, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "executed": 0, + "ignored": 5 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "executed": 9, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "executed": 0, + "ignored": 5 + }, + { + "package": "cpex-parity-harness", + "suite": "lib", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "tests/determinism.rs", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "tests/hook_table.rs", + "executed": 3, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "tests/parity.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "lib", + "executed": 4, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "tests/biscuit_e2e.rs", + "executed": 8, + "ignored": 0 + }, + { + "package": "cpex-python", + "suite": "lib", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "tests/policy_tests.rs", + "executed": 3, + "ignored": 0 + }, + { + "package": "cpex", + "suite": "doctests", + "executed": 1, + "ignored": 1 + }, + { + "package": "cpex-ffi", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "doctests", + "executed": 0, + "ignored": 0 + } + ], + "ignored_tests": [ + { + "package": "cpex-hosts-python", + "suite": "tests/acceptance_e2e.rs", + "test": "a_rejected_credential_denies_and_an_unauthenticated_route_call_is_refused", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/acceptance_e2e.rs", + "test": "python_identity_and_delegation_plugins_serve_a_ppe_route", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "test": "config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin", + "reason": "requires cpex-test-plugin installed under plugins/ with a built venv" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "test": "config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin", + "reason": "requires cpex-test-plugin installed under plugins/ with a built venv" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_capable_plugin_reads_the_raw_token_end_to_end", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_declared_capability_that_cannot_be_honored_fails_closed_end_to_end", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_non_capable_plugin_on_the_same_hook_receives_no_token", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "the_credential_never_reaches_a_log_sink_the_host_controls", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "the_real_worker_answers_the_capabilities_handshake", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "test": "an_appended_label_is_dropped_without_the_capability", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "test": "an_appended_label_survives_the_merge_when_the_capability_is_declared", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_cmf_message_the_shim_cannot_represent_fails_closed", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_real_plugin_deny_surfaces_as_a_deny_result", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_second_run_reuses_the_cached_venv", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "legacy_plugins_on_cmf_tool_pre_invoke_rewrite_and_deny_through_the_engine", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "legacy_tool_pre_invoke_round_trips_through_the_real_worker", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex", + "suite": "doctests", + "test": "crates/cpex/src/lib.rs - (line 66)", + "reason": "marked #[ignore]" + } + ] + }, + "ppe_0_5_0": { + "revision": "a149a2a plus PPE 0.5.0 upgrade", + "command": "PARITY_VALKEY_URL=redis://127.0.0.1:6392/0 cargo test --workspace --locked -- --test-threads=4", + "executed": 409, + "ignored": 17, + "suites": [ + { + "package": "cpex", + "suite": "lib", + "executed": 10, + "ignored": 0 + }, + { + "package": "cpex", + "suite": "tests/facade.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "cpex-demo-ffi", + "suite": "lib", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "lib", + "executed": 19, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "tests/egress.rs", + "executed": 8, + "ignored": 0 + }, + { + "package": "cpex-ffi", + "suite": "tests/wire_compat.rs", + "executed": 5, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "lib", + "executed": 319, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/acceptance_e2e.rs", + "executed": 0, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "executed": 1, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "executed": 0, + "ignored": 5 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "executed": 9, + "ignored": 2 + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "executed": 0, + "ignored": 5 + }, + { + "package": "cpex-parity-harness", + "suite": "lib", + "executed": 6, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "tests/determinism.rs", + "executed": 1, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "tests/hook_table.rs", + "executed": 3, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "tests/parity.rs", + "executed": 2, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "lib", + "executed": 4, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "tests/biscuit_e2e.rs", + "executed": 8, + "ignored": 0 + }, + { + "package": "cpex-python", + "suite": "lib", + "executed": 7, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "lib", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "tests/policy_tests.rs", + "executed": 3, + "ignored": 0 + }, + { + "package": "cpex", + "suite": "doctests", + "executed": 1, + "ignored": 1 + }, + { + "package": "cpex-ffi", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-hosts-python", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-parity-harness", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-plugin-delegator-biscuit", + "suite": "doctests", + "executed": 0, + "ignored": 0 + }, + { + "package": "cpex-tutorial", + "suite": "doctests", + "executed": 0, + "ignored": 0 + } + ], + "ignored_tests": [ + { + "package": "cpex-hosts-python", + "suite": "tests/acceptance_e2e.rs", + "test": "a_rejected_credential_denies_and_an_unauthenticated_route_call_is_refused", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/acceptance_e2e.rs", + "test": "python_identity_and_delegation_plugins_serve_a_ppe_route", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "test": "config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin", + "reason": "requires cpex-test-plugin installed under plugins/ with a built venv" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/config_e2e.rs", + "test": "config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin", + "reason": "requires cpex-test-plugin installed under plugins/ with a built venv" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_capable_plugin_reads_the_raw_token_end_to_end", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_declared_capability_that_cannot_be_honored_fails_closed_end_to_end", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "a_non_capable_plugin_on_the_same_hook_receives_no_token", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "the_credential_never_reaches_a_log_sink_the_host_controls", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/credential_e2e.rs", + "test": "the_real_worker_answers_the_capabilities_handshake", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "test": "an_appended_label_is_dropped_without_the_capability", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/extensions_merge_e2e.rs", + "test": "an_appended_label_survives_the_merge_when_the_capability_is_declared", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_cmf_message_the_shim_cannot_represent_fails_closed", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_real_plugin_deny_surfaces_as_a_deny_result", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "a_second_run_reuses_the_cached_venv", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "legacy_plugins_on_cmf_tool_pre_invoke_rewrite_and_deny_through_the_engine", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex-hosts-python", + "suite": "tests/isolated_venv_e2e.rs", + "test": "legacy_tool_pre_invoke_round_trips_through_the_real_worker", + "reason": "requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`" + }, + { + "package": "cpex", + "suite": "doctests", + "test": "crates/cpex/src/lib.rs - (line 66)", + "reason": "marked #[ignore]" + } + ] + } +} diff --git a/docs/plans/ppe-adoption-test-reconciliation.md b/docs/plans/ppe-adoption-test-reconciliation.md new file mode 100644 index 00000000..704f039c --- /dev/null +++ b/docs/plans/ppe-adoption-test-reconciliation.md @@ -0,0 +1,135 @@ +# PPE adoption test reconciliation + +Reconstructed on 2026-10-13 from successful executions, including doctests. The 0.2.3 tag has the same engine and builtin sources as the snapshot engine commit. This records the post-port branch as initially reviewed, rather than inventing historical counts for the intermediate cut-over commit where kept surfaces were not yet ported. + +Environment: macOS, rustc 1.96.0, default workspace features. Before: `cargo test --workspace --locked --offline` on immutable `v0.2.3` (`e3a2b30`). After: `cargo test --workspace --offline -- --test-threads=4` on `8b3cbb9` plus the Teryl review fixes, with `PARITY_VALKEY_URL` pointing to a disposable Valkey 8 database. Four test threads avoid contention between Python subprocess timing tests. One existing worker-death timing assertion failed in a concurrent run; it passed in isolation and in the recorded run. + +**Initial PPE 0.4.1 reconciliation: before 1434 executed, 39 ignored; after 408 executed, 17 ignored. Both runs had zero failures.** PPE dependency tests are not executed by the CPEX workspace command. A smaller CPEX count therefore does not establish coverage parity by itself. + +The [machine-readable inventory](ppe-adoption-test-counts.json) records counts per executable and every ignored test with its reason. Default-feature counts exclude optional-feature-only tests; the retained baseline harness is run separately against the old engine. + +| Package | Before executed | Before ignored | After executed | After ignored | +|---|---:|---:|---:|---:| +| `apl-cmf` | 61 | 1 | 0 | 0 | +| `apl-core` | 307 | 0 | 0 | 0 | +| `apl-cpex` | 212 | 1 | 0 | 0 | +| `cpex` | 2 | 2 | 13 | 1 | +| `cpex-builtins` | 3 | 0 | 0 | 0 | +| `cpex-core` | 374 | 15 | 0 | 0 | +| `cpex-demo-ffi` | 0 | 0 | 1 | 0 | +| `cpex-ffi` | 10 | 0 | 32 | 0 | +| `cpex-hosts-python` | 219 | 13 | 328 | 16 | +| `cpex-orchestration` | 7 | 0 | 0 | 0 | +| `cpex-parity-harness` | 0 | 0 | 12 | 0 | +| `cpex-pdp-cedar-direct` | 17 | 0 | 0 | 0 | +| `cpex-pdp-cel` | 32 | 1 | 0 | 0 | +| `cpex-pdp-opa` | 74 | 0 | 0 | 0 | +| `cpex-plugin-audit-logger` | 1 | 0 | 0 | 0 | +| `cpex-plugin-delegator-biscuit` | 10 | 0 | 12 | 0 | +| `cpex-plugin-delegator-oauth` | 27 | 0 | 0 | 0 | +| `cpex-plugin-elicitation-ciba` | 15 | 1 | 0 | 0 | +| `cpex-plugin-identity-jwt` | 40 | 0 | 0 | 0 | +| `cpex-plugin-pii-scanner` | 6 | 0 | 0 | 0 | +| `cpex-python` | 2 | 0 | 7 | 0 | +| `cpex-sdk` | 0 | 0 | 0 | 0 | +| `cpex-session-valkey` | 13 | 5 | 0 | 0 | +| `cpex-tutorial` | 2 | 0 | 3 | 0 | + +## Deleted suite ownership + +The following locations were checked in PPE `v0.4.1`. They identify the upstream maintenance owner, not an assertion that every old test has an identical upstream copy or was run here. Core and builtin behavior is also checked through the 68-request CPEX parity fixture set; surface acceptance covers the binding boundary. + +| Deleted CPEX package | PPE 0.4.1 owner / disposition | +|---|---| +| `cpex-core` | `crates/ppe-core/src` unit tests and `crates/ppe-core/tests` integration suites | +| `cpex-orchestration` | `crates/ppe-orchestration` | +| `cpex-sdk` | Public authoring API is in `praxis-policy-core` and the PPE facade prelude; no separately maintained SDK crate | +| `cpex-builtins` | `crates/builtins` registration and builtin tests | +| `apl-core` | `crates/ppe-apl-core` | +| `apl-cmf` | `crates/ppe-apl-cmf` | +| `apl-cpex` | `crates/ppe-apl-runtime` | +| `cpex-plugin-identity-jwt` | `crates/builtins/src/plugins/identity_jwt` and `crates/builtins/tests/jwt` | +| `cpex-plugin-delegator-oauth` | `crates/builtins/src/plugins/delegator_oauth` and `crates/builtins/tests/oauth` | +| `cpex-plugin-elicitation-ciba` | `crates/builtins/src/plugins/elicitation_ciba` and `crates/builtins/tests/ciba` | +| `cpex-pdp-cedar-direct` | `crates/builtins/src/pdps/cedar_direct` and `crates/builtins/tests/cedar` | +| `cpex-pdp-cel` | `crates/builtins/src/pdps/cel` and `crates/builtins/tests/cel` | +| `cpex-pdp-opa` | `crates/builtins/src/pdps/opa` and `crates/builtins/tests/opa` | +| `cpex-session-valkey` | `crates/builtins/src/session/valkey` and `crates/builtins/tests/valkey` | +| `cpex-plugin-pii-scanner` | Intentionally removed from CPEX; PPE keeps `reference/plugins/pii-scanner` outside its shipped builtins | +| `cpex-plugin-audit-logger` | Intentionally removed from CPEX; PPE keeps `reference/plugins/audit-logger` outside its shipped builtins | + +The imported engine/APL suites remain an upstream PPE CI responsibility. JWT, OAuth, CIBA, PDP and session behavior retain CPEX parity coverage; the Valkey read-back test explicitly seeds data written by 0.2.3. PII and audit are intentional product removals, so their deleted CPEX tests are not silently counted as passing. + +## Ignored tests + +Every ignored test is listed below. The supported legacy-worker release gate separately executed the four `make test-python-e2e` suites against SDK commit `6a38e0eb2ac399fdbce3f3ac9fcf695c5bd4518e`: 14 passed, zero ignored, zero skips. The two `config_e2e` tests still require a separately installed `cpex-test-plugin` fixture and are explicitly outside that target. The old live-Keycloak and Valkey integration tests remain upstream integration responsibilities; CPEX parity used a real disposable Valkey. + +### Before (0.2.3) + +- `cpex-hosts-python / tests/config_e2e.rs / config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin`: requires cpex-test-plugin installed under plugins/ with a built venv. +- `cpex-hosts-python / tests/config_e2e.rs / config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin`: requires cpex-test-plugin installed under plugins/ with a built venv. +- `cpex-hosts-python / tests/credential_e2e.rs / a_capable_plugin_reads_the_raw_token_end_to_end`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / a_declared_capability_that_cannot_be_honored_fails_closed_end_to_end`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / a_non_capable_plugin_on_the_same_hook_receives_no_token`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / the_credential_never_reaches_a_log_sink_the_host_controls`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / the_real_worker_answers_the_capabilities_handshake`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/extensions_merge_e2e.rs / an_appended_label_is_dropped_without_the_capability`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/extensions_merge_e2e.rs / an_appended_label_survives_the_merge_when_the_capability_is_declared`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / a_cmf_hook_is_rejected_cleanly_by_a_framework_that_has_no_cmf_hooks`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / a_real_plugin_deny_surfaces_as_a_deny_result`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / a_second_run_reuses_the_cached_venv`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / legacy_tool_pre_invoke_round_trips_through_the_real_worker`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-plugin-elicitation-ciba / tests/live_keycloak.rs / live_dispatch_then_pending`: requires a live Keycloak. +- `cpex-session-valkey / tests/valkey_store_integration.rs / cross_node_concurrent_append_unions`: marked #[ignore]. +- `cpex-session-valkey / tests/valkey_store_integration.rs / ttl_set_on_append_and_refreshed_on_load`: marked #[ignore]. +- `cpex-session-valkey / tests/valkey_store_integration.rs / unknown_session_returns_empty_ok`: marked #[ignore]. +- `cpex-session-valkey / tests/valkey_store_integration.rs / unreachable_endpoint_fails_closed`: marked #[ignore]. +- `cpex-session-valkey / tests/valkey_store_integration.rs / wrongtype_reply_fails_closed`: marked #[ignore]. +- `apl-cmf / doctests / crates/apl-cmf/src/lib.rs - BagBuilder (line 84)`: marked #[ignore]. +- `apl-cpex / doctests / crates/apl-cpex/src/register.rs - register::register_apl (line 133)`: marked #[ignore]. +- `cpex / doctests / crates/cpex/src/lib.rs - (line 46)`: marked #[ignore]. +- `cpex / doctests / crates/cpex/src/lib.rs - (line 75)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/delegation/hook.rs - delegation::hook::TokenDelegateHook (line 55)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/elicitation/hook.rs - elicitation::hook::ElicitationHook (line 50)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/extensions/container.rs - extensions::container::Extensions::cow_copy (line 162)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/factory.rs - factory::PluginFactory (line 39)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/factory.rs - factory::PluginFactoryRegistry (line 85)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/hooks/macros.rs - hooks::macros::define_hook (line 21)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/hooks/macros.rs - hooks::macros::define_hook (line 38)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/hooks/trait_def.rs - hooks::trait_def::HookHandler (line 102)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/identity/hook.rs - identity::hook::IdentityHook (line 65)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/manager.rs - manager::PluginManager::invoke_named (line 1086)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/manager.rs - manager::PluginManager::load_config_file (line 464)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/manager.rs - manager::PluginManager::register_factory (line 438)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/manager.rs - manager::PluginManager::register_handler (line 740)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/manager.rs - manager::PluginManager::register_handler_for_names (line 771)`: marked #[ignore]. +- `cpex-core / doctests / crates/cpex-core/src/plugin.rs - plugin::Plugin (line 47)`: marked #[ignore]. +- `cpex-pdp-cel / doctests / builtins/pdps/cel/src/resolver.rs - resolver::CelResolver::with_functions (line 152)`: marked #[ignore]. + +### After (0.3.0) + +- `cpex-hosts-python / tests/acceptance_e2e.rs / a_rejected_credential_denies_and_an_unauthenticated_route_call_is_refused`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/acceptance_e2e.rs / python_identity_and_delegation_plugins_serve_a_ppe_route`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/config_e2e.rs / config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin`: requires cpex-test-plugin installed under plugins/ with a built venv. +- `cpex-hosts-python / tests/config_e2e.rs / config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin`: requires cpex-test-plugin installed under plugins/ with a built venv. +- `cpex-hosts-python / tests/credential_e2e.rs / a_capable_plugin_reads_the_raw_token_end_to_end`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / a_declared_capability_that_cannot_be_honored_fails_closed_end_to_end`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / a_non_capable_plugin_on_the_same_hook_receives_no_token`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / the_credential_never_reaches_a_log_sink_the_host_controls`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/credential_e2e.rs / the_real_worker_answers_the_capabilities_handshake`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/extensions_merge_e2e.rs / an_appended_label_is_dropped_without_the_capability`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/extensions_merge_e2e.rs / an_appended_label_survives_the_merge_when_the_capability_is_declared`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / a_cmf_message_the_shim_cannot_represent_fails_closed`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / a_real_plugin_deny_surfaces_as_a_deny_result`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / a_second_run_reuses_the_cached_venv`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / legacy_plugins_on_cmf_tool_pre_invoke_rewrite_and_deny_through_the_engine`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex-hosts-python / tests/isolated_venv_e2e.rs / legacy_tool_pre_invoke_round_trips_through_the_real_worker`: requires python3 + a cpex Python checkout (CPEX_PYTHON_SOURCE); run via `make test-python-e2e`. +- `cpex / doctests / crates/cpex/src/lib.rs - (line 66)`: marked #[ignore]. + +## PPE 0.5.0 upgrade validation + +The 2026-10-13 upgrade pins every resolved PPE crate to 0.5.0. The same workspace command with four test threads and a real disposable Valkey executed **409 tests, with 17 explicitly ignored and zero failures**. The extra executed test checks secret-header provenance across the Python host boundary. The `ppe_0_5_0` entry in the machine-readable inventory lists every executable and ignored test for this run. + +The 68-request parity run has 53 exact matches and 15 requests differing in 38 explicitly reviewed fields. One decision intentionally changes: a second subject reusing a redeemed CIBA approval now denies. Delegated-token keys also gain delegator and target identity fields. The original 0.2.3 snapshots are unchanged. + +The Python binding suite passed 70 tests, the Go static-FFI race suite passed, and the four legacy SDK e2e suites passed all 14 tests with no skips. Workspace and all-feature facade clippy checks and the tutorial offline checks also passed. Further upstream fixes remain tracked in [PPE #208](https://github.com/praxis-proxy/policy/issues/208) and [PPE #209](https://github.com/praxis-proxy/policy/issues/209), to be implemented in a separate PPE PR before CPEX adopts a released fix. diff --git a/docs/specs/cmf-message-spec.md b/docs/specs/cmf-message-spec.md index bafd9d55..ddb34fec 100644 --- a/docs/specs/cmf-message-spec.md +++ b/docs/specs/cmf-message-spec.md @@ -71,7 +71,7 @@ The CMF is designed to satisfy the following goals: Define a provider-agnostic message format that decouples transport wire protocols from internal processing, enabling consistent handling across LLM providers, agent frameworks, tools, and enforcement systems. * **Complete and explicit enforcement surface** - Represent all policy-relevant data—including identity, access control metadata, governance attributes, execution context, and provenance—as structured, typed fields on the message. + Represent all policy-relevant data, including identity, access control metadata, governance attributes, execution context, and provenance, as structured, typed fields on the message. * **Integrity and safety by construction** Protect security-relevant data through explicit mutability tiers (immutable, monotonic, guarded, mutable) enforced by the processing pipeline, and enable safe read-only inspection for policy evaluation. @@ -332,7 +332,7 @@ Execution environment and request-level timing/tracing. Available to all consume ### 3.4 AgentExtension (immutable) -Agent execution context — session tracking, multi-agent lineage, and the original user intent. Immutable because the user's intent and session identity must not be modifiable by processing components. +Agent execution context: session tracking, multi-agent lineage, and the original user intent. Immutable because the user's intent and session identity must not be modifiable by processing components. | Attribute | Type | Description | |-----------|------|-------------| @@ -354,7 +354,7 @@ Agent execution context — session tracking, multi-agent lineage, and the origi ### 3.5 HttpExtension (guarded) -HTTP request context. Readable with `read_headers`, writable with `write_headers`. The write capability exists because consumers sometimes need to inject headers for downstream systems — e.g., adding an OAuth token for an API call, or a correlation ID for tracing. +HTTP request context. Readable with `read_headers`, writable with `write_headers`. The write capability exists because consumers sometimes need to inject headers for downstream systems: e.g., adding an OAuth token for an API call, or a correlation ID for tracing. | Attribute | Read Capability | Write Capability | Type | Description | |-----------|----------------|-----------------|------|-------------| @@ -408,9 +408,9 @@ objects: dict[str, ObjectSecurityProfile] = {} | `trust_domain` | `str \| None` | Trust domain: `"internal"`, `"external"`, `"privileged"` | | `data_scope` | `list[str]` | Field names this entity accesses/returns (e.g., `["salary", "bonus"]`) | -For MCP/framework messages (single entity), this map has one entry. For LLM provider messages with multiple tool calls, it has one entry per entity. The host pipeline populates this map during message ingestion by looking up each entity's registered profile from an implementation-defined registry (e.g., tool registration metadata, MCP server manifests, or a policy store). Profile lookup is keyed by entity identifier — the tool name, resource URI, or prompt name that appears in the message content. +For MCP/framework messages (single entity), this map has one entry. For LLM provider messages with multiple tool calls, it has one entry per entity. The host pipeline populates this map during message ingestion by looking up each entity's registered profile from an implementation-defined registry (e.g., tool registration metadata, MCP server manifests, or a policy store). Profile lookup is keyed by entity identifier: the tool name, resource URI, or prompt name that appears in the message content. -The `MessageView` exposes a singular accessor — each view resolves its own profile from the map: +The `MessageView` exposes a singular accessor: each view resolves its own profile from the map: ``` view.object → ObjectSecurityProfile | None @@ -489,11 +489,11 @@ Typed metadata about the MCP entity being processed. Gives consumers access to t |-----------|------|-------------| | `name` | `str` | Prompt template name | | `description` | `str \| None` | Prompt description | -| `arguments` | `list[dict] \| None` | Argument definitions — each entry has `name`, `description`, `required` | +| `arguments` | `list[dict] \| None` | Argument definitions: each entry has `name`, `description`, `required` | | `server_id` | `str \| None` | ID of the server providing this prompt | | `annotations` | `dict` | MCP annotations | -Note: Prompts use an argument list rather than JSON Schema for input definition, following the MCP prompt specification. There is no output schema — prompt output is always rendered messages. +Note: Prompts use an argument list rather than JSON Schema for input definition, following the MCP prompt specification. There is no output schema: prompt output is always rendered messages. ### 3.8 CompletionExtension (immutable) @@ -556,7 +556,7 @@ Agentic framework context. Captures the framework-level execution environment fo Host-provided operational metadata about the entity being processed. Set by the host system (gateway registration, static config, MCP manifest, admin UI) before the plugin pipeline runs. Plugins can read this data for routing and policy decisions but cannot modify it. -`MetaExtension` is protocol-agnostic — it carries the same structure regardless of whether the entity came from MCP, A2A, gRPC, or REST. +`MetaExtension` is protocol-agnostic: it carries the same structure regardless of whether the entity came from MCP, A2A, gRPC, or REST. | Attribute | Type | Description | |-----------|------|-------------| @@ -578,7 +578,7 @@ Custom extensions. Fully mutable through copy-on-write, no restrictions on modif ## 4. MessageView -`Message` is the **storage format** — it preserves the wire structure exactly as the LLM sent it. `MessageView` is the **policy and interaction surface** — it decomposes a message into individually addressable parts, enriches each with computed semantics, and provides a uniform interface regardless of content type. +`Message` is the **storage format**: it preserves the wire structure exactly as the LLM sent it. `MessageView` is the **policy and interaction surface**: it decomposes a message into individually addressable parts, enriches each with computed semantics, and provides a uniform interface regardless of content type. ### 4.1 Message vs. MessageView @@ -601,8 +601,8 @@ Calling `message.iter_views()` produces **four MessageViews**, each with a unifo | # | `kind` | `name` | `action` | `is_pre` | `uri` | `content` | |---|--------|--------|----------|----------|-------|-----------| -| 1 | `thinking` | — | `generate` | `false` | — | `"The user wants admin users..."` | -| 2 | `text` | — | `send` | `false` | — | `"Let me look that up for you."` | +| 1 | `thinking` | none | `generate` | `false` | none | `"The user wants admin users..."` | +| 2 | `text` | none | `send` | `false` | none | `"Let me look that up for you."` | | 3 | `tool_call` | `execute_sql` | `execute` | `true` | `tool://db-server/execute_sql` | `'{"query": "SELECT..."}'` | | 4 | `tool_call` | `send_email` | `execute` | `true` | `tool://email-server/send_email` | `'{"to": "boss@..."}'` | @@ -632,7 +632,7 @@ An MCP tool invocation is simply a Message with one `tool_call` content part: {"role": "assistant", "content": [{"content_type": "tool_call", "name": "get_user", "arguments": {"id": "123"}}]} ``` -This produces one view. An OpenAI assistant response that bundles reasoning with two tool calls produces three views. The processing pipeline doesn't care — `iter_views()` yields the right number either way, and every view has the same interface. +This produces one view. An OpenAI assistant response that bundles reasoning with two tool calls produces three views. The processing pipeline doesn't care: `iter_views()` yields the right number either way, and every view has the same interface. This means the CMF does not force a choice between "one action per message" (MCP, A2A) and "bundled response" (LLM providers). Both are first-class, and the same policies and routing rules work across both patterns without adaptation. @@ -678,7 +678,7 @@ Direction is determined by a combination of ViewKind and Role: ### 4.5 Flat Accessors (capability-gated) -MessageView provides flat accessor properties over extensions. These hide the underlying extension nesting — consumers write `view.roles`, not `view.extensions.security.subject.roles`. Availability depends on the consumer's declared capabilities — extensions for which access has not been granted are `None` on the view. +MessageView provides flat accessor properties over extensions. These hide the underlying extension nesting: consumers write `view.roles`, not `view.extensions.security.subject.roles`. Availability depends on the consumer's declared capabilities: extensions for which access has not been granted are `None` on the view. | Accessor | Capability | Type | Reads From | |----------|-----------|------|------------| diff --git a/docs/specs/cpex-go-spec.md b/docs/specs/cpex-go-spec.md index 55d21757..819f71db 100644 --- a/docs/specs/cpex-go-spec.md +++ b/docs/specs/cpex-go-spec.md @@ -1,8 +1,10 @@ -# CPEX Go — Public API Specification +# CPEX Go: Public API Specification -**Status**: Draft +**Status**: Design reference, names updated for CPEX 0.3.0 (C ABI v3) **Date**: May 2026 -**Source**: `github.com/contextforge-org/contextforge-plugins-framework/go/cpex` +**Source**: `github.com/contextforge-org/cpex/go/cpex` + +> **Read this first.** This spec was written for CPEX 0.2. In 0.3.0 the Go package wraps the praxis-policy engine (PPE) through C ABI v3. Names below are updated (`PolicyEngine`, `NewPolicyEngine`, `cpex_engine_*`, PPE hook names), and the config example uses the 0.3.0 shape. New in 0.3.0 and not covered in depth here: `EnableAPL(opts...)` with `WithPrivateDestinations()`, `RefreshSecrets`, `SecretsLastSuccess`, and `PayloadIdentity`. The [Go package README](https://github.com/contextforge-org/cpex/tree/main/go/cpex) is the current reference, and the [migration guide](https://contextforge-org.github.io/cpex/docs/migration-0.3/) maps every 0.2 name. CPEX Go is the Golang consumption API for the ContextForge Plugin Extension Framework (CPEX). It embeds the Rust plugin runtime in-process via CGo/FFI, providing Go host systems with a high-performance hook-based extensibility layer. Payloads and extensions cross the FFI boundary as MessagePack bytes; plugin execution happens entirely in the Rust async runtime. @@ -12,8 +14,8 @@ CPEX Go is the Golang consumption API for the ContextForge Plugin Extension Fram ┌──────────────────────────────────────────────────────┐ │ Go Host (e.g., AuthBridge) │ │ │ -│ PluginManager ───────────────────────────────┐ │ -│ │ NewPluginManager[Default]() │ │ +│ PolicyEngine ───────────────────────────────┐ │ +│ │ NewPolicyEngine[Default]() │ │ │ │ RegisterFactories(fn) │ │ │ │ LoadConfig(yaml) │ │ │ │ Initialize() │ │ @@ -25,10 +27,10 @@ CPEX Go is the Golang consumption API for the ContextForge Plugin Extension Fram │ │ CGo / MessagePack │ ├────────────────────────┼─────────────────────────────┤ │ libcpex_ffi (Rust) ▼ │ -│ cpex_manager_new / cpex_invoke / cpex_shutdown │ +│ cpex_engine_new / cpex_invoke / cpex_shutdown │ │ ┌─────────────────────────────────────────────┐ │ -│ │ cpex-core (Rust) │ │ -│ │ • PluginManager → Executor → Plugins │ │ +│ │ praxis-policy-core (Rust, PPE) │ │ +│ │ • PolicyEngine → Executor → Plugins │ │ │ │ • tokio runtime (async plugin execution) │ │ │ │ • Phase ordering, capability gating │ │ │ │ • Route resolution, policy composition │ │ @@ -40,12 +42,12 @@ CPEX Go is the Golang consumption API for the ContextForge Plugin Extension Fram - Plugins are written in Rust (native) and compiled into `libcpex_ffi`. The Go layer is the host embedding API, not a plugin authoring API. - The FFI boundary uses MessagePack for payloads/extensions and opaque handles for stateful objects (ContextTable, BackgroundTasks). -- All `PluginManager`s in a process share a single tokio runtime (process-singleton via `OnceLock`). Async plugin execution works from synchronous CGo calls without exploding thread count under multi-tenant hosts. Worker thread count is configurable — see §5.8. +- All `PolicyEngine`s in a process share a single tokio runtime (process-singleton via `OnceLock`). Async plugin execution works from synchronous CGo calls without exploding thread count under multi-tenant hosts. Worker thread count is configurable: see §5.8. ## 2. Package & Import ```go -import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +import cpex "github.com/contextforge-org/cpex/go/cpex" ``` **Dependencies:** @@ -72,13 +74,13 @@ CGo links against `libcpex_ffi` from `target/release/`. [ConfigureRuntime(N)] ← optional, package-level, before any manager │ ▼ -NewPluginManagerDefault() +NewPolicyEngineDefault() │ ▼ RegisterFactories(fn) ← register Rust plugin factories via callback │ ▼ -LoadConfig(yaml) ← YAML with plugin definitions, routing, policies +LoadConfig(yaml) ← YAML with plugin definitions, routes, groups │ ▼ Initialize() ← instantiate and wire all plugins @@ -95,7 +97,7 @@ Shutdown() ← graceful teardown | Operation | Method | |---|---| | Configure runtime (optional) | `cpex.ConfigureRuntime(workerThreads)` | -| Create manager | `NewPluginManagerDefault()` or `NewPluginManager(yaml)` | +| Create manager | `NewPolicyEngineDefault()` or `NewPolicyEngine(yaml)` | | Register factories | `mgr.RegisterFactories(fn)` | | Load config | `mgr.LoadConfig(yaml)` | | Initialize | `mgr.Initialize()` | @@ -111,39 +113,39 @@ Shutdown() ← graceful teardown | Thread context | Pass returned `*ContextTable` to next invoke | | Wait background | `bg.Wait()` returns `([]PluginError, error)` | | Release background | `bg.Close()` | -| Classify error | `errors.Is(err, ErrCpexTimeout)` (and other sentinels — §14) | +| Classify error | `errors.Is(err, ErrCpexTimeout)` (and other sentinels: §14) | | Shutdown | `mgr.Shutdown()` | ## 5. Core Types -### 5.1 PluginManager +### 5.1 PolicyEngine The top-level object. Owns the Rust runtime and plugin registry. ```go -type PluginManager struct { /* opaque CGo handle, sync.RWMutex */ } +type PolicyEngine struct { /* opaque CGo handle, sync.RWMutex */ } // Construction -func NewPluginManager(yaml string) (*PluginManager, error) -func NewPluginManagerDefault() (*PluginManager, error) +func NewPolicyEngine(yaml string) (*PolicyEngine, error) +func NewPolicyEngineDefault() (*PolicyEngine, error) // Factory registration -func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error +func (m *PolicyEngine) RegisterFactories(fn FactoryRegistrar) error // Configuration -func (m *PluginManager) LoadConfig(yaml string) error +func (m *PolicyEngine) LoadConfig(yaml string) error // Initialization -func (m *PluginManager) Initialize() error +func (m *PolicyEngine) Initialize() error // Query -func (m *PluginManager) HasHooksFor(hookName string) bool -func (m *PluginManager) PluginCount() int -func (m *PluginManager) IsInitialized() bool -func (m *PluginManager) PluginNames() ([]string, error) +func (m *PolicyEngine) HasHooksFor(hookName string) bool +func (m *PolicyEngine) PluginCount() int +func (m *PolicyEngine) IsInitialized() bool +func (m *PolicyEngine) PluginNames() ([]string, error) // Invocation -func (m *PluginManager) InvokeByName( +func (m *PolicyEngine) InvokeByName( hookName string, payloadType uint8, payload any, @@ -153,7 +155,7 @@ func (m *PluginManager) InvokeByName( // Typed invocation (generics) func Invoke[P any]( - m *PluginManager, + m *PolicyEngine, hookName string, payloadType uint8, payload P, @@ -162,14 +164,14 @@ func Invoke[P any]( ) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) // Teardown -func (m *PluginManager) Shutdown() +func (m *PolicyEngine) Shutdown() ``` **Notes:** -- `NewPluginManager(yaml)` creates the manager AND loads config in one call (factories auto-registered). -- `NewPluginManagerDefault()` creates an empty manager — call `RegisterFactories` then `LoadConfig` separately. +- `NewPolicyEngine(yaml)` creates the manager and loads config in one call; it registers no builtins. For the bundled builtins, call `NewPolicyEngineDefault()`, `EnableAPL()`, then `LoadConfig()`. +- `NewPolicyEngineDefault()` creates an empty manager: call `RegisterFactories` then `LoadConfig` separately. - A Go finalizer calls `Shutdown()` if the caller forgets, but explicit `Shutdown()` is recommended. -- The `PluginManager` wrapper holds a `sync.RWMutex` so `Shutdown()` cannot race with concurrent `Invoke*` calls; Operation methods take the read lock, lifecycle methods take the write lock. +- The `PolicyEngine` wrapper holds a `sync.RWMutex` so `Shutdown()` cannot race with concurrent `Invoke*` calls; Operation methods take the read lock, lifecycle methods take the write lock. - `PluginNames()` returns the registered plugin names in registration order (no guaranteed sort). ### 5.2 FactoryRegistrar @@ -221,9 +223,9 @@ func (bg *BackgroundTasks) Close() Handle to fire-and-forget tasks spawned by plugins (e.g., async audit logging). Tasks run in the shared Rust tokio runtime outside the request's latency budget. -- `Wait()` blocks until all background tasks complete. Returns a structured `[]PluginError` from any that failed (typed shape — `PluginName`, `Code`, `Message`, etc.; see §5.7) plus an `error` for FFI-level failures (e.g., the manager was shut down between invoke and wait — returns `ErrCpexInvalidHandle`). -- `Close()` releases the handle without waiting — tasks continue running. -- The handle holds a `*PluginManager` reference and checks `mgr.handle != nil` under the manager's lock before calling into Rust, so `Wait()` after `Shutdown()` is safe (returns `ErrCpexInvalidHandle` rather than dereferencing freed memory). +- `Wait()` blocks until all background tasks complete. Returns a structured `[]PluginError` from any that failed (typed shape: `PluginName`, `Code`, `Message`, etc.; see §5.7) plus an `error` for FFI-level failures (e.g., the manager was shut down between invoke and wait: returns `ErrCpexInvalidHandle`). +- `Close()` releases the handle without waiting: tasks continue running. +- The handle holds a `*PolicyEngine` reference and checks `mgr.handle != nil` under the manager's lock before calling into Rust, so `Wait()` after `Shutdown()` is safe (returns `ErrCpexInvalidHandle` rather than dereferencing freed memory). ### 5.5 PipelineResult @@ -243,7 +245,7 @@ func (r *PipelineResult) DeserializeExtensions() (*Extensions, error) func DeserializePayload[T any](result *PipelineResult) (*T, error) ``` -`Errors` carries structured records for failures from plugins that ran with `on_error: ignore` or `on_error: disable` — previously these were only logged and invisible to callers. Use them to drive retry logic, dashboards, or audit trails. See §13.6 for the consumption pattern, and §5.7 for the synthetic FFI-layer record. +`Errors` carries structured records for failures from plugins that ran with `on_error: ignore` or `on_error: disable`: previously these were only logged and invisible to callers. Use them to drive retry logic, dashboards, or audit trails. See §13.6 for the consumption pattern, and §5.7 for the synthetic FFI-layer record. ### 5.6 TypedPipelineResult @@ -275,7 +277,7 @@ type PluginError struct { } ``` -Structured plugin failure record. Used by `PipelineResult.Errors`, `TypedPipelineResult[P].Errors`, and `BackgroundTasks.Wait()`. All entries are framework-emitted — plugins influence the record (via the error they return) but cannot forge `PluginName`, which is set by the executor from the registered plugin metadata. +Structured plugin failure record. Used by `PipelineResult.Errors`, `TypedPipelineResult[P].Errors`, and `BackgroundTasks.Wait()`. All entries are framework-emitted: plugins influence the record (via the error they return) but cannot forge `PluginName`, which is set by the executor from the registered plugin metadata. **Reserved synthetic plugin names:** @@ -306,7 +308,7 @@ Structured denial. `Code` is a machine-readable identifier; `Reason` is a short func ConfigureRuntime(workerThreads int) error ``` -Sets the worker thread count for the shared tokio runtime that backs every `PluginManager` in the process. **Must** be called before the first `NewPluginManager*` — once a manager has been created the runtime is fixed for process lifetime. +Sets the worker thread count for the shared tokio runtime that backs every `PolicyEngine` in the process. **Must** be called before the first `NewPolicyEngine*`: once a manager has been created the runtime is fixed for process lifetime. ```go // In main(), before any manager construction: @@ -317,11 +319,11 @@ if err := cpex.ConfigureRuntime(8); err != nil { **Precedence (highest first):** -1. `ConfigureRuntime(N)` — explicit FFI call, before first use. -2. `CPEX_FFI_WORKER_THREADS` env var — operator-friendly default. Read once on lazy init. -3. tokio default (`num_cpus`) — when neither knob is set. +1. `ConfigureRuntime(N)`: explicit FFI call, before first use. +2. `CPEX_FFI_WORKER_THREADS` env var: operator-friendly default. Read once on lazy init. +3. tokio default (`num_cpus`): when neither knob is set. -Use case: multi-tenant hosts that want to bound total worker threads regardless of how many `PluginManager`s are alive (one per tenant, dynamic plugin reload, etc.). Without this knob, N managers × `num_cpus` workers each can blow up the OS thread count. +Use case: multi-tenant hosts that want to bound total worker threads regardless of how many `PolicyEngine`s are alive (one per tenant, dynamic plugin reload, etc.). Without this knob, N managers × `num_cpus` workers each can blow up the OS thread count. ## 6. Extensions @@ -412,7 +414,7 @@ The `capabilities` list in a plugin's YAML config controls which extension field The Rust write pattern uses COW (copy-on-write) ownership: ```rust -// In Plugin::handle — capability-gated extension modification +// In Plugin::handle: capability-gated extension modification let mut owned = extensions.cow_copy(); // clone mutable slots if let Some(ref token) = owned.http_write_token { // token present iff capability declared @@ -445,8 +447,8 @@ CPEX uses a `payloadType` discriminator to tell the Rust core how to deserialize | Constant | Value | Payload Type | |---|---|---| -| `PayloadGeneric` | `0` | `map[string]any` — untyped JSON-like payload | -| `PayloadCMFMessage` | `1` | `MessagePayload` — CMF message | +| `PayloadGeneric` | `0` | `map[string]any`: untyped JSON-like payload | +| `PayloadCMFMessage` | `1` | `MessagePayload`: CMF message | Hosts define their own payload structs (e.g., `InboundPreValidationPayload`) and serialize them as `PayloadGeneric`. The type ID tells Rust how to deserialize; Go callers choose the ID and matching struct. @@ -459,12 +461,12 @@ payload := map[string]any{ "tool_name": "get_compensation", "user": "alice", } -result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", cpex.PayloadGeneric, payload, ext, nil) +result, ct, bg, err := mgr.InvokeByName("demo.tool_pre_invoke", cpex.PayloadGeneric, payload, ext, nil) ``` ### 7.3 CMF MessagePayload -The ContextForge Message Format — a typed, multi-part message with schema versioning. +The ContextForge Message Format: a typed, multi-part message with schema versioning. ```go type MessagePayload struct { @@ -502,7 +504,7 @@ func NewMessage(role string, content ...ContentPart) Message Constructors follow Go's `NewXyz` convention so they don't shadow the like-named struct fields on `ContentPart` (e.g., the `ToolCallContent *ToolCall` field vs the `NewToolCallPart` constructor). -Unknown `content_type` discriminators are preserved on decode via an internal `rawMap` and re-emitted unchanged on encode — so a Go host running an older SDK against a newer Rust runtime won't silently drop content parts it doesn't recognize. +Unknown `content_type` discriminators are preserved on decode via an internal `rawMap` and re-emitted unchanged on encode: so a Go host running an older SDK against a newer Rust runtime won't silently drop content parts it doesn't recognize. **Example:** @@ -525,37 +527,24 @@ result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( ## 8. Hook Types (Built-in) -Hooks are open strings — hosts define their own. The following are built into `cpex-core`: +Hosts may define their own hooks. The following are built into the engine. The 0.2 unprefixed names (`tool_pre_invoke`, `prompt_pre_fetch`, `identity_resolve`, `token_delegate`, ...) are rejected at config load. -### 8.1 Legacy Hooks (typed payloads) +### 8.1 Engine Hooks -| Hook Name | Lifecycle Stage | -|---|---| -| `tool_pre_invoke` | Before tool execution | -| `tool_post_invoke` | After tool execution | -| `prompt_pre_fetch` | Before prompt template fetch | -| `prompt_post_fetch` | After prompt template fetch | -| `resource_pre_fetch` | Before resource fetch | -| `resource_post_fetch` | After resource fetch | -| `identity_resolve` | Identity resolution | -| `token_delegate` | Token delegation | - -### 8.2 CMF Hooks (MessagePayload) - -| Hook Name | Lifecycle Stage | -|---|---| -| `cmf.tool_pre_invoke` | Before tool execution (CMF message) | -| `cmf.tool_post_invoke` | After tool execution (CMF message) | -| `cmf.llm_input` | Before LLM call | -| `cmf.llm_output` | After LLM response | -| `cmf.prompt_pre_fetch` | Before prompt fetch (CMF) | -| `cmf.prompt_post_fetch` | After prompt fetch (CMF) | -| `cmf.resource_pre_fetch` | Before resource fetch (CMF) | -| `cmf.resource_post_fetch` | After resource fetch (CMF) | +| Hook Name | Lifecycle Stage | Payload | +|---|---|---| +| `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` | Around tool execution | `MessagePayload` | +| `cmf.llm_input` / `cmf.llm_output` | Around an LLM call | `MessagePayload` | +| `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` | Around a prompt fetch | `MessagePayload` | +| `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` | Around a resource fetch | `MessagePayload` | +| `identity.resolve` | Identity resolution | `IdentityPayload` (`PayloadIdentity`) | +| `token.delegate` | Token delegation | engine-internal | +| `elicit` | Elicitation channel | engine-internal | +| `http.request` / `http.response` | Generic HTTP | `HttpPayload` (not usable from Go in 0.3.0) | -### 8.3 Custom Hooks +### 8.2 Custom Hooks -Hosts register their own hook names. Any string works: +Hosts register their own hook names. The config loader refuses a hook name it has no metadata for, so the host's Rust FFI crate registers the metadata (`praxis_policy_core::hooks::register_hook_metadata`) before a config names it: ```go mgr.InvokeByName("inbound.pre_validation", cpex.PayloadGeneric, payload, ext, nil) @@ -567,68 +556,66 @@ mgr.InvokeByName("outbound.pre_exchange", cpex.PayloadGeneric, payload, ext, nil Plugins are declared in YAML and loaded via `LoadConfig`. The YAML is parsed by the Rust core. ```yaml -plugin_settings: - routing_enabled: true - plugin_timeout: 30 - -global: - policies: - all: - plugins: [identity-checker] - pii: - plugins: [pii-guard] +engine_settings: + plugin_timeout: 30 # dispatch: policy is the default plugins: - name: identity-checker - kind: builtin/identity - hooks: [tool_pre_invoke, tool_post_invoke] - mode: sequential - priority: 10 + kind: demo/identity + hooks: [identity.resolve] on_error: fail - name: pii-guard - kind: builtin/pii - hooks: [tool_pre_invoke] - mode: sequential - priority: 20 + kind: demo/pii + hooks: [cmf.tool_pre_invoke] on_error: fail capabilities: - read_labels - read_subject - name: audit-logger - kind: builtin/audit - hooks: [tool_pre_invoke, tool_post_invoke] + kind: demo/audit + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] mode: fire_and_forget - priority: 100 on_error: ignore - name: header-injector - kind: builtin/cmf-header-injector + kind: demo/cmf-header-injector hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] - mode: sequential - priority: 50 on_error: ignore capabilities: - read_headers - write_headers +global: + authentication: [identity-checker] + authorization: + pre_invocation: + - "run(audit-logger)" + - "run(header-injector)" + +groups: + pii: + authorization: + pre_invocation: + - "run(pii-guard)" + routes: - # Tool-specific route — tags applied to all invocations of this tool + # Tool-specific route: joins the pii group - tool: get_compensation - meta: - tags: [pii, hr] - plugins: - - audit-logger + groups: [pii] + authorization: + pre_invocation: + - "require(role.hr)" - tool: list_departments - plugins: - - audit-logger + authorization: + pre_invocation: ["allow"] - # Wildcard route — applies to all tools not matched above + # Wildcard route: applies to all tools not matched above - tool: "*" - plugins: - - audit-logger + authorization: + pre_invocation: ["allow"] ``` ### 9.1 Plugin Modes @@ -661,30 +648,33 @@ The optional `capabilities` list controls which extension fields a plugin can re | `read_headers` | `HttpExtension.request_headers` (read) | | `write_headers` | `HttpExtension.response_headers` (read + write token) | -Capabilities declared in YAML are enforced at the Rust core level — a plugin cannot write to extensions it did not declare. See §6.4 for the Rust-side write pattern. +Capabilities declared in YAML are enforced at the Rust core level: a plugin cannot write to extensions it did not declare. See §6.4 for the Rust-side write pattern. ### 9.4 Routes -Routes match invocations by tool name and apply additional plugin overrides or tag injection. Evaluated in order; first match wins. The `"*"` wildcard matches any tool not matched by an earlier route. +Routes match invocations by tool name and carry the policy for that tool. The most specific match wins; the `"*"` wildcard matches any tool not matched by another route. Under the default `dispatch: policy`, a route names the plugins it runs with `run(name)` steps; the 0.2 route `plugins:` list is a load error. ```yaml routes: - # Exact match — injects meta tags for this tool's invocations + # Exact match: injects meta tags for this tool's invocations - tool: get_compensation meta: tags: [pii, hr] - plugins: - - audit-logger + authorization: + pre_invocation: + - "run(audit-logger)" - # Exact match — no meta tags + # Exact match: no meta tags - tool: list_departments - plugins: - - audit-logger + authorization: + pre_invocation: + - "run(audit-logger)" - # Wildcard — catch-all for remaining tools + # Wildcard: catch-all for remaining tools - tool: "*" - plugins: - - audit-logger + authorization: + pre_invocation: + - "run(audit-logger)" ``` The `meta.tags` field under a route entry augments (or sets) the `MetaExtension.Tags` seen by plugins for that tool, enabling tag-based policy groups to trigger without requiring the Go caller to set tags on every invocation. @@ -701,7 +691,7 @@ import ( "os" "unsafe" - cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" + cpex "github.com/contextforge-org/cpex/go/cpex" ) /* @@ -715,7 +705,7 @@ import "C" func main() { // 1. Create manager - mgr, err := cpex.NewPluginManagerDefault() + mgr, err := cpex.NewPolicyEngineDefault() if err != nil { panic(err) } @@ -761,7 +751,7 @@ func main() { } result, ct, bg, err := mgr.InvokeByName( - "tool_pre_invoke", cpex.PayloadGeneric, + "demo.tool_pre_invoke", cpex.PayloadGeneric, map[string]any{"tool_name": "get_compensation", "user": "alice"}, ext, nil, ) @@ -778,7 +768,7 @@ func main() { // 6. Thread context into post-invoke result2, ct2, bg2, err := mgr.InvokeByName( - "tool_post_invoke", cpex.PayloadGeneric, + "demo.tool_post_invoke", cpex.PayloadGeneric, map[string]any{"tool_name": "get_compensation", "result": "..."}, ext, ct, // pass context from pre-invoke ) @@ -826,7 +816,7 @@ Check for registered plugins before constructing payloads: ```go if !mgr.HasHooksFor("inbound.pre_validation") { - // No plugins configured — skip payload construction and FFI overhead + // No plugins configured: skip payload construction and FFI overhead return handleRequestDirectly(req) } @@ -855,13 +845,13 @@ if result.IsDenied() { ### 13.2 Modified Payload ```go -// Raw path — manual deserialization +// Raw path: manual deserialization if len(result.ModifiedPayload) > 0 { modified, err := cpex.DeserializePayload[MyPayload](result) // use modified } -// Typed path — automatic deserialization +// Typed path: automatic deserialization typed, ct, bg, err := cpex.Invoke[MyPayload](mgr, hook, payloadType, payload, ext, nil) if typed.ModifiedPayload != nil { // use typed.ModifiedPayload directly @@ -883,7 +873,7 @@ if len(result.ModifiedExtensions) > 0 { // Option A: Wait for background tasks (e.g., at request boundary) bgErrors, err := bg.Wait() if err != nil { - // FFI-level failure — e.g., ErrCpexInvalidHandle if the manager + // FFI-level failure: e.g., ErrCpexInvalidHandle if the manager // was shut down between Invoke and Wait. The handle is still // safely consumed; no need to call Close after a Wait error. log.Warn("bg.Wait failed:", err) @@ -920,13 +910,13 @@ if result.IsDenied() { /* halted by a fail/deny plugin */ } // Pipeline ran to completion. Inspect any soft errors. for _, e := range result.Errors { if e.PluginName == "" { - // Framework-emitted — e.g., the modified payload couldn't be + // Framework-emitted: e.g., the modified payload couldn't be // re-serialized across the FFI boundary. The rest of the // result is still valid; the plugin's modification was // dropped. metrics.Inc("cpex.ffi_serialize_error") } else { - // Plugin-attributed — the plugin failed but ran with + // Plugin-attributed: the plugin failed but ran with // on_error: ignore/disable, so we got here. Code is the // plugin's machine-readable identifier. log.Warn("plugin %s failed [%s]: %s", e.PluginName, e.Code, e.Message) @@ -934,11 +924,11 @@ for _, e := range result.Errors { } ``` -Note that `result.Errors` is *separate* from `result.Violation` — a violation halts the pipeline (no further plugins run); errors recorded here mean the pipeline kept going. +Note that `result.Errors` is *separate* from `result.Violation`: a violation halts the pipeline (no further plugins run); errors recorded here mean the pipeline kept going. ## 14. Error Handling -CPEX Go classifies errors via typed sentinels. Use `errors.Is(err, ErrCpexX)` rather than string-matching `err.Error()` — the message text is not part of the API. +CPEX Go classifies errors via typed sentinels. Use `errors.Is(err, ErrCpexX)` rather than string-matching `err.Error()`: the message text is not part of the API. ### 14.1 Sentinels @@ -950,7 +940,7 @@ var ( // Shutdown. ErrCpexInvalidHandle = errors.New("cpex: invalid handle ...") - // ErrCpexInvalidInput: caller-supplied input was malformed — + // ErrCpexInvalidInput: caller-supplied input was malformed: // bad UTF-8 in hookName, payloadType out of range, oversized // buffer, etc. Calling code bug. ErrCpexInvalidInput = errors.New("cpex: invalid input") @@ -960,7 +950,7 @@ var ( // format mismatch between Go and Rust struct definitions. ErrCpexParse = errors.New("cpex: parse / deserialize failed") - // ErrCpexPipeline: pipeline / lifecycle step failed — + // ErrCpexPipeline: pipeline / lifecycle step failed: // load_config returned Err, initialize returned Err, or a // plugin signalled failure during invoke (without timeout or // panic). The plugin's structured error is in result.Errors @@ -968,7 +958,7 @@ var ( ErrCpexPipeline = errors.New("cpex: pipeline / lifecycle error") // ErrCpexSerialize: result serialization failed after the - // pipeline ran — usually OOM on rmp_serde::to_vec_named, or an + // pipeline ran: usually OOM on rmp_serde::to_vec_named, or an // unserializable JSON value. Distinct from the per-modified- // payload synthetic error in result.Errors (see §5.7). ErrCpexSerialize = errors.New("cpex: result serialize failed") @@ -977,11 +967,11 @@ var ( // exceeded. A plugin is likely CPU-bound or blocking the OS // thread without yielding. Rust per-plugin timeouts only // catch cooperative-async timeouts; this catches the rest. - ErrCpexTimeout = errors.New("cpex: wall-clock timeout") + ErrCpexTimeout = errors.New("cpex: wall-clock timeout exceeded") - // ErrCpexPanic: a plugin panicked; caught by catch_unwind at - // the FFI boundary. Indicates a bug in plugin Rust code. - ErrCpexPanic = errors.New("cpex: plugin panicked") + // ErrCpexPanic: an FFI-layer panic. A plugin panic is returned + // as a denied result with Violation.Code == "plugin_panic". + ErrCpexPanic = errors.New("cpex: panic at FFI boundary") ) ``` @@ -995,19 +985,23 @@ if err != nil { metrics.Inc("cpex.timeout") return retryWithBackoff(req) case errors.Is(err, cpex.ErrCpexPanic): - // Plugin bug — log, alert, fail closed. + // FFI-layer panic: log, alert, fail closed. metrics.Inc("cpex.panic") return denyOnPluginPanic() case errors.Is(err, cpex.ErrCpexInvalidHandle): - // Manager has been shut down — recreate or fail closed. + // Manager has been shut down: recreate or fail closed. return errors.New("plugin runtime offline") default: - // ErrCpexParse / Serialize / Pipeline / InvalidInput — + // ErrCpexParse / Serialize / Pipeline / InvalidInput: // typically caller or config bugs. log.Error("cpex invoke:", err) return err } } +if result.IsDenied() && result.Violation != nil && result.Violation.Code == "plugin_panic" { + metrics.Inc("cpex.plugin_panic") + return denyOnPluginPanic() +} ``` ### 14.3 Two error channels @@ -1016,8 +1010,8 @@ CPEX Go reports failures through two distinct channels, and they have different | Channel | Triggers | Meaning | |---|---|---| -| `error` return value | FFI-level failures (timeout, panic, parse, invalid handle) | The pipeline did not complete usefully — `result` is `nil` | -| `result.Errors` | Plugin failures with `on_error: ignore` or `on_error: disable`; FFI-layer modified-payload serialize failures | The pipeline ran to completion — `result` is valid; treat as soft errors | +| `error` return value | FFI-level failures (timeout, panic, parse, invalid handle) | The pipeline did not complete usefully: `result` is `nil` | +| `result.Errors` | Plugin failures with `on_error: ignore` or `on_error: disable`; FFI-layer modified-payload serialize failures | The pipeline ran to completion: `result` is valid; treat as soft errors | A pipeline can return `err == nil`, `result.IsDenied() == false`, AND non-empty `result.Errors`. That means: "everything ran, nothing halted, but here are the things that didn't work." Don't ignore `result.Errors` just because `err` was nil. @@ -1027,15 +1021,15 @@ All types use `msgpack` struct tags matching Rust field names for zero-copy seri **Rules:** - Go struct fields map 1:1 to Rust struct fields via `msgpack:"field_name"` tags. -- Optional fields use `omitempty` — nil/zero values are not serialized. +- Optional fields use `omitempty`: nil/zero values are not serialized. - `ContentPart` uses custom `EncodeMsgpack`/`DecodeMsgpack` for tagged-union encoding. - Byte slices (`[]byte`) are serialized as MessagePack binary, not arrays. ## 16. Thread Safety -- `PluginManager` is safe for concurrent use from multiple goroutines. The Go wrapper holds a `sync.RWMutex` so concurrent `Invoke*` calls take the read lock while `Shutdown` takes the write lock — preventing a use-after-free if Shutdown lands between an in-flight invoke and its FFI return. -- The Rust core uses `ArcSwap` for the registry — concurrent invokes read a stable snapshot; mutations clone-and-swap. This means an in-flight invoke sees the registry as it was when the invoke started, not as it is mid-call. -- `ContextTable` is NOT safe for concurrent use — it represents per-request state that is threaded sequentially through hook invocations. +- `PolicyEngine` is safe for concurrent use from multiple goroutines. The Go wrapper holds a `sync.RWMutex` so concurrent `Invoke*` calls take the read lock while `Shutdown` takes the write lock: preventing a use-after-free if Shutdown lands between an in-flight invoke and its FFI return. +- The Rust core uses `ArcSwap` for the registry: concurrent invokes read a stable snapshot; mutations clone-and-swap. This means an in-flight invoke sees the registry as it was when the invoke started, not as it is mid-call. +- `ContextTable` is NOT safe for concurrent use: it represents per-request state that is threaded sequentially through hook invocations. - `BackgroundTasks` is safe to call `Wait()` or `Close()` from any goroutine, but only once. ## 17. Gaps and Unimplemented Features @@ -1044,15 +1038,15 @@ The following features exist in the Python CPEX implementation but are not yet e | Feature | Python Location | Status in Go | |---|---|---| -| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented — no single-plugin invoke | +| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented: no single-plugin invoke | | `HookPayloadPolicy` (field-level write control) | `manager.py` / `hooks/policies.py` | Handled in Rust core via plugin capabilities, not configurable from Go | -| `TenantPluginManager` (per-tenant isolation) | `manager.py` | Not implemented — single global manager only (multi-tenant hosts can use one manager per tenant since Pass 9's shared runtime caps total threads) | -| Plugin introspection | `hooks/registry.py` | Partial — `HasHooksFor`, `PluginCount`, `PluginNames`, `IsInitialized` exposed; per-hook plugin lookup is not | -| Observability provider injection | `manager.py` | Not exposed — observability configured in Rust | +| `TenantPolicyEngine` (per-tenant isolation) | `manager.py` | Not implemented: single global manager only (multi-tenant hosts can use one manager per tenant since Pass 9's shared runtime caps total threads) | +| Plugin introspection | `hooks/registry.py` | Partial: `HasHooksFor`, `PluginCount`, `PluginNames`, `IsInitialized` exposed; per-hook plugin lookup is not | +| Observability provider injection | `manager.py` | Not exposed: observability configured in Rust | | Plugin conditions (runtime skip) | `manager.py` | Handled in Rust core via YAML config (`MatchContext` evaluated against extensions) | | `OnError.DISABLE` runtime status query | `manager.py` | Not exposed (errors from disabled plugins surface in `result.Errors` though) | -| `reset()` (reinitialize without restart) | `manager.py` | Not implemented — shutdown and recreate | -| Programmatic capability gating | `extensions/tiers.py` | YAML-only — capabilities declared per-plugin in config (§9.3); no runtime API to override or rebind capabilities per-invoke | +| `reset()` (reinitialize without restart) | `manager.py` | Not implemented: shutdown and recreate | +| Programmatic capability gating | `extensions/tiers.py` | YAML-only: capabilities declared per-plugin in config (§9.3); no runtime API to override or rebind capabilities per-invoke | | gRPC/Unix/MCP external plugin transports | `framework/external/` | Not yet in Rust core | | Plugin loader with search paths | `loader/` | Rust uses factory registration instead | | PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet in Rust core | @@ -1076,7 +1070,7 @@ The repo ships a Makefile with the canonical commands. The raw `cargo` / `go` in | `make go-test` / `go-test-race` | Go tests (with optional race detector) | | `make go-lint-check` | Read-only `gofmt -l` + `go vet` + `golangci-lint run` | | `make go-lint` (or `go-lint-fix`) | Mutating: `gofmt -w` + `vet` + `golangci-lint run --fix` | -| `make examples-build` | Build all 4 examples — catches stale public-API usage | +| `make examples-build` | Build all 4 examples: catches stale public-API usage | | `make examples-run` | Build + run each example end-to-end | | `make test-all` | `rust-test` + `go-test-race` (the canonical "everything") | | `make ci` | `rust-lint-check` + `test-all` + `examples-build` (the CI gate) | diff --git a/docs/specs/cpex-rust-spec.md b/docs/specs/cpex-rust-spec.md index eeb2be29..ce483165 100644 --- a/docs/specs/cpex-rust-spec.md +++ b/docs/specs/cpex-rust-spec.md @@ -1,15 +1,17 @@ -# CPEX Rust — Public API Specification +# CPEX Rust: Public API Specification -**Status**: Draft -**Date**: May 2026 -**Source**: `crates/cpex-core` in `github.com/contextforge-org/contextforge-plugins-framework` +**Status**: Design reference. Superseded in CPEX 0.3.0, where this runtime moved to the praxis-policy engine (PPE) 0.4.1. +**Date**: May 2026, names updated for 0.3.0 +**Source**: originally `crates/cpex-core`; now `praxis-policy-core` on crates.io, re-exported by the `cpex` facade -CPEX Rust is the core plugin runtime — pure Rust, no FFI/WASM/PyO3 dependencies. It serves two audiences: +> **Read this first.** This spec describes the plugin runtime as designed for CPEX 0.2. In 0.3.0 the runtime is PPE's `praxis-policy-core`, consumed through the `cpex` facade. Type and function names below are updated to 0.3.0 (`PolicyEngine`, `PolicyEngineConfig`, `praxis_policy_core::...`), and config snippets use the 0.3.0 shape. Module paths, signatures and the YAML key set may still differ in detail: the authoritative reference is [docs.rs/praxis-policy-core/0.4.1](https://docs.rs/praxis-policy-core/0.4.1) and the CPEX docs site. Host-defined hooks in the examples use a `demo.` prefix: the engine rejects the old unprefixed names (`tool_pre_invoke`, ...), and a host hook needs registered metadata (`praxis_policy_core::hooks::register_hook_metadata`, or the `define_hooks!` macro) before a config names it. The [migration guide](https://contextforge-org.github.io/cpex/docs/migration-0.3/) maps every 0.2 name. -- **Embedders** — Rust hosts that want an in-process plugin pipeline (configure → load → invoke). -- **Plugin authors** — code that runs *inside* the runtime as native Rust plugins (define hooks, write `HookHandler` impls). +CPEX Rust is the core plugin runtime: pure Rust, no FFI/WASM/PyO3 dependencies. It serves two audiences: -The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer of cpex-core via cpex-ffi. Other language bindings layer the same way. This spec documents the Rust API directly, with a focus on plugin authoring (§6, §11). +- **Embedders**: Rust hosts that want an in-process plugin pipeline (configure → load → invoke). +- **Plugin authors**: code that runs *inside* the runtime as native Rust plugins (define hooks, write `HookHandler` impls). + +The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer of the engine via cpex-ffi. Other language bindings layer the same way. This spec documents the Rust API directly, with a focus on plugin authoring (§6, §11). ## 1. Architecture @@ -17,9 +19,9 @@ The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer ┌──────────────────────────────────────────────────────┐ │ Rust Host │ │ │ -│ PluginManager ───────────────────────────────┐ │ -│ │ PluginManager::new(ManagerConfig) │ │ -│ │ PluginManager::from_config(path, factories)│ │ +│ PolicyEngine ───────────────────────────────┐ │ +│ │ PolicyEngine::new(PolicyEngineConfig) │ │ +│ │ PolicyEngine::from_config(path, factories)│ │ │ │ register_handler::(plugin, config) │ │ │ │ initialize().await │ │ │ │ invoke::(payload, ext, ct).await │ │ @@ -32,11 +34,11 @@ The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer │ Executor ▼ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 5-Phase Pipeline │ │ -│ │ 1. Sequential — block + modify │ │ -│ │ 2. Transform — modify only │ │ -│ │ 3. Audit — read-only, serial │ │ -│ │ 4. Concurrent — block-only, parallel │ │ -│ │ 5. FireAndForget — background tasks │ │ +│ │ 1. Sequential : block + modify │ │ +│ │ 2. Transform : modify only │ │ +│ │ 3. Audit : read-only, serial │ │ +│ │ 4. Concurrent : block-only, parallel │ │ +│ │ 5. FireAndForget: background tasks │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ ├────────────────────────┼─────────────────────────────┤ @@ -49,16 +51,17 @@ The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer **Key design decisions:** -- **Typed dispatch is the default.** The recommended API is `invoke::(payload, ...)`, where `H: HookTypeDef` carries the payload type at compile time. The compiler enforces payload/hook compatibility — there's no `Box` in user code on the happy path. -- **Hook types are open** — hosts define their own via the `HookTypeDef` trait or the `define_hook!` macro. cpex-core ships built-ins (`tool_pre_invoke`, CMF hooks) but does not require them. -- **Capabilities at config** — extension visibility and write authority are declared in YAML (or programmatically on `PluginConfig`). The executor enforces them by handing out `WriteToken`s only for declared capabilities. +- **Typed dispatch is the default.** The recommended API is `invoke::(payload, ...)`, where `H: HookTypeDef` carries the payload type at compile time. The compiler enforces payload/hook compatibility: there's no `Box` in user code on the happy path. +- **Hook types are open**: hosts define their own via the `HookTypeDef` trait or the `define_hook!` macro. The engine ships built-in hooks (the CMF, HTTP, identity, delegation and elicitation families) but does not require them. +- **Capabilities at config**: extension visibility and write authority are declared in YAML (or programmatically on `PluginConfig`). The executor enforces them by handing out `WriteToken`s only for declared capabilities. - **Async-by-default handler.** Both plugin lifecycle (`initialize`, `shutdown`) and the per-invocation `handle(...)` are `async`. Handlers that don't need to await anything compile to a trivially-ready future that LLVM inlines, so there is no cost over a plain function call. Handlers that do need to await just `.await` inside the body. See §6.2 for the cost breakdown and the guidance on when to put `.await` in `handle`. ## 2. Crate Layout & Dependencies ```toml [dependencies] -cpex-core = { git = "https://github.com/contextforge-org/contextforge-plugins-framework", branch = "main" } +cpex = "0.3" # a host: the facade re-exports the engine +praxis-policy-core = "=0.4.1" # a plugin crate: the same version cpex pins async-trait = "0.1" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } @@ -69,24 +72,24 @@ serde_json = "1" | Module | Purpose | |---|---| -| `cpex_core::plugin` | `Plugin` trait, `PluginConfig`, `PluginMode`, `OnError`, `PluginCondition`, `MatchContext` | -| `cpex_core::hooks` | `HookTypeDef`, `HookHandler`, `PluginPayload`, `PluginResult

`, `define_hook!` | -| `cpex_core::factory` | `PluginFactory`, `PluginInstance`, `PluginFactoryRegistry` | -| `cpex_core::manager` | `PluginManager`, `ManagerConfig` | -| `cpex_core::executor` | `PipelineResult`, `BackgroundTasks` | -| `cpex_core::registry` | `HookEntry`, `PluginRef`, `group_by_mode` (rarely used directly) | -| `cpex_core::config` | `CpexConfig`, `load_config`, `parse_config` | -| `cpex_core::extensions` | `Extensions`, `OwnedExtensions`, all extension types, `WriteToken`, `Guarded`, `MonotonicSet` | -| `cpex_core::error` | `PluginError`, `PluginViolation`, `PluginErrorRecord` | -| `cpex_core::cmf` | CMF `MessagePayload`, `Message`, `ContentPart` | -| `cpex_core::context` | `PluginContext`, `PluginContextTable` | - -There are no feature flags currently — everything is built unconditionally. cpex-ffi (the C ABI surface) lives in a separate crate and is not part of this spec. +| `praxis_policy_core::plugin` | `Plugin` trait, `PluginConfig`, `PluginMode`, `OnError`, `PluginCondition`, `MatchContext` | +| `praxis_policy_core::hooks` | `HookTypeDef`, `HookHandler`, `PluginPayload`, `PluginResult

`, `define_hook!` | +| `praxis_policy_core::factory` | `PluginFactory`, `PluginInstance`, `PluginFactoryRegistry` | +| `praxis_policy_core::manager` | `PolicyEngine`, `PolicyEngineConfig` | +| `praxis_policy_core::executor` | `PipelineResult`, `BackgroundTasks` | +| `praxis_policy_core::registry` | `HookEntry`, `PluginRef`, `group_by_mode` (rarely used directly) | +| `praxis_policy_core::config` | `CpexConfig`, `load_config`, `parse_config` | +| `praxis_policy_core::extensions` | `Extensions`, `OwnedExtensions`, all extension types, `WriteToken`, `Guarded`, `MonotonicSet` | +| `praxis_policy_core::error` | `PluginError`, `PluginViolation`, `PluginErrorRecord` | +| `praxis_policy_core::cmf` | CMF `MessagePayload`, `Message`, `ContentPart` | +| `praxis_policy_core::context` | `PluginContext`, `PluginContextTable` | + +There are no feature flags currently: everything is built unconditionally. cpex-ffi (the C ABI surface) lives in a separate crate and is not part of this spec. ## 3. Lifecycle ``` -ManagerConfig::default() → PluginManager::new() +PolicyEngineConfig::default() → PolicyEngine::new() │ ▼ register_factory(kind, factory) ← optional, for kind-driven loading @@ -108,10 +111,10 @@ shutdown().await ← calls Plugin::shutdown() on each | Operation | Method | |---|---| -| Create manager | `PluginManager::new(ManagerConfig::default())` | +| Create manager | `PolicyEngine::new(PolicyEngineConfig::default())` | | Register factory | `mgr.register_factory(kind, Box::new(MyFactory))` | | Load YAML config | `mgr.load_config(cpex_config)` or `mgr.load_config_file(path)` | -| Build from config | `PluginManager::from_config(path, &factories)` | +| Build from config | `PolicyEngine::from_config(path, &factories)` | | Programmatic register | `mgr.register_handler::(plugin, config)` | | Multiple hook names | `mgr.register_handler_for_names::(plugin, config, &names)` | | Initialize | `mgr.initialize().await` | @@ -123,7 +126,7 @@ shutdown().await ← calls Plugin::shutdown() on each | **Invoke (typed, primary)** | **`mgr.invoke::(payload, ext, ct).await`** | | Invoke (typed + runtime name) | `mgr.invoke_named::(name, payload, ext, ct).await` | | Invoke (untyped fallback) | `mgr.invoke_by_name(name, payload, ext, ct).await` | -| Define hook type | `define_hook!{ ToolPreInvoke; "tool_pre_invoke" => Payload(P) -> Result(R); }` | +| Define hook type | `define_hook!{ ToolPreInvoke; "demo.tool_pre_invoke" => Payload(P) -> Result(R); }` | | Define payload | `impl_plugin_payload!(MyPayload)` | | Handler | `impl HookHandler for MyPlugin { async fn handle(...) -> H::Result { ... } }` | | Allow result | `PluginResult::allow()` | @@ -136,17 +139,17 @@ shutdown().await ← calls Plugin::shutdown() on each ## 5. Core Types -### 5.1 PluginManager +### 5.1 PolicyEngine The top-level object. Owns the plugin registry, factory registry, hook adapter table, and executor. ```rust -pub struct PluginManager { /* private — uses ArcSwap */ } +pub struct PolicyEngine { /* private: uses ArcSwap */ } -impl PluginManager { +impl PolicyEngine { // Construction - pub fn new(config: ManagerConfig) -> Self; - pub fn default() -> Self; // ManagerConfig::default() + pub fn new(config: PolicyEngineConfig) -> Self; + pub fn default() -> Self; // PolicyEngineConfig::default() pub fn from_config( path: &Path, factories: &PluginFactoryRegistry, @@ -204,7 +207,7 @@ impl PluginManager { pub fn get_plugin(&self, name: &str) -> Option>; pub fn unregister(&self, name: &str) -> Option>; - // Invocation — see §5.2 for the three flavors and when to use each + // Invocation: see §5.2 for the three flavors and when to use each pub async fn invoke( &self, payload: H::Payload, @@ -232,10 +235,10 @@ impl PluginManager { **Notes:** -- `register_handler` is the **programmatic** registration path — you supply the `Arc

` directly. It does not require a `PluginFactory`. Use this for plugins compiled into the same binary as the host. -- `from_config` is the **config-driven** path — it reads YAML, looks up each plugin's `kind` in the factory registry, and calls `factory.create(config)` to instantiate. Use this for plugins selected by config. +- `register_handler` is the **programmatic** registration path: you supply the `Arc

` directly. It does not require a `PluginFactory`. Use this for plugins compiled into the same binary as the host. +- `from_config` is the **config-driven** path: it reads YAML, looks up each plugin's `kind` in the factory registry, and calls `factory.create(config)` to instantiate. Use this for plugins selected by config. - Both paths can coexist: register infrastructure plugins programmatically, then `load_config` to add the YAML-driven ones. -- Invoke methods take `&self` and are `async` — multiple concurrent invokes are supported. The internal registry uses `ArcSwap` for lock-free reads. +- Invoke methods take `&self` and are `async`: multiple concurrent invokes are supported. The internal registry uses `ArcSwap` for lock-free reads. ### 5.2 Choosing an Invoke Method @@ -249,7 +252,7 @@ let (result, bg) = mgr.invoke::(payload, ext, ct).await; let (result, bg) = mgr.invoke_named::("cmf.tool_pre_invoke", payload, ext, ct).await; // Last resort: type-erased payload (FFI/bridge code that already holds Box) -let (result, bg) = mgr.invoke_by_name("tool_pre_invoke", boxed_payload, ext, ct).await; +let (result, bg) = mgr.invoke_by_name("demo.tool_pre_invoke", boxed_payload, ext, ct).await; ``` | Method | Payload type | Hook name source | When to use | @@ -258,24 +261,24 @@ let (result, bg) = mgr.invoke_by_name("tool_pre_invoke", boxed_payload, ext, ct) | `invoke_named::` | `H::Payload` (compile-time checked) | `&str` arg | One hook *type* covers multiple hook *names*. Used by the CMF pattern: `CmfHook` carries `MessagePayload` and is registered under `cmf.tool_pre_invoke`, `cmf.llm_input`, etc. | | `invoke_by_name` | `Box` (type-erased) | `&str` arg | Bridge / FFI code that has already type-erased the payload (e.g., cpex-ffi after MessagePack deserialization). Avoid in user code. | -All three return `(PipelineResult, BackgroundTasks)` directly — no `Result`. The pipeline itself can fail (a plugin denied, a plugin errored with `on_error: fail`), but those are surfaced through `PipelineResult.violation`, `PipelineResult.errors`, and the `continue_processing` flag — see §13. +All three return `(PipelineResult, BackgroundTasks)` directly: no `Result`. The pipeline itself can fail (a plugin denied, a plugin errored with `on_error: fail`), but those are surfaced through `PipelineResult.violation`, `PipelineResult.errors`, and the `continue_processing` flag: see §13. -If the hook name has no registered handlers, all three short-circuit to `PipelineResult::allowed_with(payload, extensions, ct)` and return immediately — zero overhead beyond a registry lookup. +If the hook name has no registered handlers, all three short-circuit to `PipelineResult::allowed_with(payload, extensions, ct)` and return immediately: zero overhead beyond a registry lookup. -### 5.3 ManagerConfig +### 5.3 PolicyEngineConfig ```rust -pub struct ManagerConfig { +pub struct PolicyEngineConfig { pub default_timeout: Duration, pub default_on_error: OnError, pub max_route_cache_size: usize, - /* additional fields — see manager.rs */ + /* additional fields: see manager.rs */ } -impl Default for ManagerConfig { /* sensible defaults */ } +impl Default for PolicyEngineConfig { /* sensible defaults */ } ``` -`ManagerConfig::default()` is fine for most hosts. Override `max_route_cache_size` if you have an exceptionally large set of `routes:` in YAML; override `default_timeout` for tighter SLAs. +`PolicyEngineConfig::default()` is fine for most hosts. Override `max_route_cache_size` if you have an exceptionally large set of `routes:` in YAML; override `default_timeout` for tighter SLAs. ### 5.4 PluginConfig @@ -294,7 +297,7 @@ pub struct PluginConfig { pub on_error: OnError, // fail / ignore / disable pub capabilities: HashSet, // extension read/write gates pub tags: Vec, - pub conditions: Vec, // legacy scope filtering (ignored when routing_enabled) + pub conditions: Vec, // hook-dispatch scope filtering (a load error under dispatch: policy) pub config: Option, // plugin-specific settings (opaque to framework) } ``` @@ -330,7 +333,7 @@ Modes determine *both* the phase the plugin runs in *and* the authority it has. | `Audit` | 3 | `&Payload` | No | No | | `Concurrent` | 4 | `&Payload` | Yes | No | | `FireAndForget` | 5 | `&Payload` | No | No | -| `Disabled` | — | not invoked | — | — | +| `Disabled` | none | not invoked | none | none | ### 5.6 OnError @@ -367,13 +370,13 @@ impl PipelineResult { The aggregate output of running all phases for one invoke: -- `continue_processing` — `false` if any sequential plugin denied. The host should halt downstream work. -- `violation` — populated when a plugin denied; carries the structured reason. -- `modified_payload` — present only if at least one Sequential or Transform plugin produced a modification. Type-erased here for the same reason `invoke_by_name` exists: the executor drops below the type-parameter level. Downcast via `as_any()`, or use the typed-result helper in §5.8. -- `modified_extensions` — present only if at least one capability-holding plugin called `modify_extensions(...)`. -- `errors` — soft errors from `Ignore`/`Disable` plugins. Read these to surface non-fatal failures to logs/dashboards. -- `metadata` — free-form aggregation key-value across plugins. Useful for `_decision_plugin`-style markers. -- `context_table` — per-plugin state to thread into the next invoke. +- `continue_processing`: `false` if any sequential plugin denied. The host should halt downstream work. +- `violation`: populated when a plugin denied; carries the structured reason. +- `modified_payload`: present only if at least one Sequential or Transform plugin produced a modification. Type-erased here for the same reason `invoke_by_name` exists: the executor drops below the type-parameter level. Downcast via `as_any()`, or use the typed-result helper in §5.8. +- `modified_extensions`: present only if at least one capability-holding plugin called `modify_extensions(...)`. +- `errors`: soft errors from `Ignore`/`Disable` plugins. Read these to surface non-fatal failures to logs/dashboards. +- `metadata`: free-form aggregation key-value across plugins. Useful for `_decision_plugin`-style markers. +- `context_table`: per-plugin state to thread into the next invoke. ### 5.8 PluginResult<P> @@ -410,7 +413,7 @@ if let Some(boxed) = result.modified_payload.as_ref() { } ``` -If you stayed on the typed `invoke::` path, the `H::Payload` you sent in is the type to downcast to — no surprises. +If you stayed on the typed `invoke::` path, the `H::Payload` you sent in is the type to downcast to: no surprises. ### 5.9 PluginError @@ -447,7 +450,7 @@ return Err(PluginError::Config { message: "missing policy_file".into(), }.boxed()); -// `?` works automatically — From for Box is in std: +// `?` works automatically: From for Box is in std: let cfg: MyConfig = serde_json::from_value(raw)?; // serde error ↗ Box ``` @@ -512,7 +515,7 @@ Every plugin implements `Plugin`. It carries the plugin's config and the lifecyc ```rust #[async_trait] pub trait Plugin: Send + Sync { - /// The plugin's configuration. Read-only — the framework holds + /// The plugin's configuration. Read-only: the framework holds /// the authoritative copy in `PluginRef.trusted_config`. fn config(&self) -> &PluginConfig; @@ -546,7 +549,7 @@ pub trait HookHandler: Plugin + Send + Sync { } ``` -The `fn ... -> impl Future` shape is **native AFIT** (Associated Fn In Trait, stable since Rust 1.75). Plugin authors write the impl with the more familiar `async fn` form — it desugars to the same thing: +The `fn ... -> impl Future` shape is **native AFIT** (Associated Fn In Trait, stable since Rust 1.75). Plugin authors write the impl with the more familiar `async fn` form: it desugars to the same thing: ```rust impl HookHandler for AllowPlugin { @@ -577,13 +580,13 @@ impl HookHandler for AuthzPlugin { **Borrow semantics:** -- `payload: &H::Payload` — always a borrow. The executor's mode-aware adapter passes either a clone (Sequential/Transform) or a true reference (Audit/Concurrent/FireAndForget). The plugin sees `&` either way; if it needs to mutate, it `clone()`s and returns `PluginResult::modify_payload(modified)`. -- `extensions: &Extensions` — capability-filtered view. Slots the plugin lacks read capabilities for appear as `None`. -- `ctx: &mut PluginContext` — per-plugin state. Read/write the local state map; stage updates to global state via `ctx.set_global(...)`. +- `payload: &H::Payload`: always a borrow. The executor's mode-aware adapter passes either a clone (Sequential/Transform) or a true reference (Audit/Concurrent/FireAndForget). The plugin sees `&` either way; if it needs to mutate, it `clone()`s and returns `PluginResult::modify_payload(modified)`. +- `extensions: &Extensions`: capability-filtered view. Slots the plugin lacks read capabilities for appear as `None`. +- `ctx: &mut PluginContext`: per-plugin state. Read/write the local state map; stage updates to global state via `ctx.set_global(...)`. -**Async by design.** `handle` is `async fn`. Plugins that don't need to await anything still write `async fn handle(...)` and return synchronously — the compiler emits a trivially-ready future and LLVM inlines it at the adapter site, so there is no observable runtime cost over a plain function call. Plugins that *do* need to await (fresh JWKS fetch, RPC to authz, dynamic policy lookup) just use `.await` inside the body. +**Async by design.** `handle` is `async fn`. Plugins that don't need to await anything still write `async fn handle(...)` and return synchronously: the compiler emits a trivially-ready future and LLVM inlines it at the adapter site, so there is no observable runtime cost over a plain function call. Plugins that *do* need to await (fresh JWKS fetch, RPC to authz, dynamic policy lookup) just use `.await` inside the body. -**Registration is the same for both.** A single `register_handler::` call accepts a plugin whose `handle` body is purely sync as well as one that genuinely awaits — the trait doesn't distinguish. +**Registration is the same for both.** A single `register_handler::` call accepts a plugin whose `handle` body is purely sync as well as one that genuinely awaits: the trait doesn't distinguish. ```rust manager.register_handler::(plugin, config)?; @@ -592,13 +595,13 @@ manager.register_handler::(plugin, config)?; **Cost:** - Plugins with no `.await` in `handle` compile to a `Ready` future that the executor awaits; LLVM typically inlines this to a direct call. No heap allocation, no scheduler interaction. -- Plugins that actually await pay normal async cost (one boxed future at the type-erased `AnyHookHandler` boundary, plus whatever the awaited work costs). Native AFIT is what avoids per-call boxing at the typed layer — `#[async_trait]` would have boxed every call. +- Plugins that actually await pay normal async cost (one boxed future at the type-erased `AnyHookHandler` boundary, plus whatever the awaited work costs). Native AFIT is what avoids per-call boxing at the typed layer: `#[async_trait]` would have boxed every call. -**When to put `.await` in `handle`:** prefer caching at init time and reading from cache on the hot path — that is the most common source of latency regressions in plugins. Only put `.await` in `handle` when caching genuinely won't work (e.g., per-request decisions against authoritative state). +**When to put `.await` in `handle`:** prefer caching at init time and reading from cache on the hot path: that is the most common source of latency regressions in plugins. Only put `.await` in `handle` when caching genuinely won't work (e.g., per-request decisions against authoritative state). ### 6.3 The `PluginPayload` Trait -The base trait for all hook payloads. Object-safe — the framework dispatches via `Box` internally, but plugin code rarely sees that directly when using `invoke::`. +The base trait for all hook payloads. Object-safe: the framework dispatches via `Box` internally, but plugin code rarely sees that directly when using `invoke::`. ```rust pub trait PluginPayload: Send + Sync + 'static { @@ -611,7 +614,7 @@ pub trait PluginPayload: Send + Sync + 'static { Implement it via the macro: ```rust -use cpex_core::impl_plugin_payload; +use praxis_policy_core::impl_plugin_payload; #[derive(Debug, Clone)] struct ToolInvokePayload { @@ -622,27 +625,27 @@ struct ToolInvokePayload { impl_plugin_payload!(ToolInvokePayload); ``` -The macro expands to the three method impls — saves boilerplate per type. Requirements: the type must be `Clone + Send + Sync + 'static`. No `Serialize` is required by `PluginPayload` itself, but payloads that cross the FFI boundary (and so are deserializable from MessagePack) typically derive `serde::Serialize + Deserialize` too. +The macro expands to the three method impls: saves boilerplate per type. Requirements: the type must be `Clone + Send + Sync + 'static`. No `Serialize` is required by `PluginPayload` itself, but payloads that cross the FFI boundary (and so are deserializable from MessagePack) typically derive `serde::Serialize + Deserialize` too. ### 6.4 Defining a Hook Type A hook type is a zero-sized marker struct that implements `HookTypeDef`. It associates a name (for registry lookup) with a typed payload and result. ```rust -use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use praxis_policy_core::hooks::trait_def::{HookTypeDef, PluginResult}; struct ToolPreInvoke; impl HookTypeDef for ToolPreInvoke { type Payload = ToolInvokePayload; type Result = PluginResult; - const NAME: &'static str = "tool_pre_invoke"; + const NAME: &'static str = "demo.tool_pre_invoke"; } ``` **Conventions:** -- `type Result = PluginResult` — the standard shape. Custom result types are possible (the trait doesn't require `PluginResult`) but the executor wires `H::Result: Into>` so anything you return must convert into one. -- `NAME` is the lookup key for `register_handler::(...)` and the `hooks: [tool_pre_invoke]` line in YAML. It's also what `invoke::` uses for dispatch — so calling `invoke::` is exactly equivalent to `invoke_by_name(H::NAME, ...)` with the type advantages. +- `type Result = PluginResult`: the standard shape. Custom result types are possible (the trait doesn't require `PluginResult`) but the executor wires `H::Result: Into>` so anything you return must convert into one. +- `NAME` is the lookup key for `register_handler::(...)` and the `hooks: [demo.tool_pre_invoke]` line in YAML. It's also what `invoke::` uses for dispatch: so calling `invoke::` is exactly equivalent to `invoke_by_name(H::NAME, ...)` with the type advantages. - One marker can be shared across multiple hook *names* if your plugin handles a family. See the CMF pattern in §6.6. #### `define_hook!` macro (sugar) @@ -650,16 +653,16 @@ impl HookTypeDef for ToolPreInvoke { For the common case, a macro generates the marker struct, the trait impl, and a `HookHandler` shorthand in one declaration: ```rust -use cpex_core::define_hook; +use praxis_policy_core::define_hook; define_hook! { - /// Hook for tool_pre_invoke. + /// Hook for demo.tool_pre_invoke. ToolPreInvoke; - "tool_pre_invoke" => Payload(ToolInvokePayload) -> Result(PluginResult); + "demo.tool_pre_invoke" => Payload(ToolInvokePayload) -> Result(PluginResult); } ``` -Either form is fine — manual when you want fine control over docs/derives, the macro for less typing. +Either form is fine: manual when you want fine control over docs/derives, the macro for less typing. ### 6.5 PluginResult Constructors @@ -673,11 +676,11 @@ The four canonical outcomes a plugin signals: | `PluginResult::modify_extensions(owned)` | Pass. Apply extension changes (capability-gated). | | `PluginResult::modify(p, owned)` | Pass. Both payload and extension changes. | -Audit / Concurrent / FireAndForget plugins should only use `allow()` and `deny()` — `modify_*` calls in those modes are dropped by the executor (the plugin lacks the authority). +Audit / Concurrent / FireAndForget plugins should only use `allow()` and `deny()`: `modify_*` calls in those modes are dropped by the executor (the plugin lacks the authority). ### 6.6 Multiple Hooks per Plugin -A single plugin can implement `HookHandler` for several hook types. Each `impl HookHandler` block is independent — they can share `&self` state but don't have to. +A single plugin can implement `HookHandler` for several hook types. Each `impl HookHandler` block is independent: they can share `&self` state but don't have to. ```rust impl HookHandler for IdentityResolver { @@ -697,12 +700,12 @@ Register each separately: ```rust manager.register_handler::( - Arc::clone(&plugin), config_for("tool_pre_invoke"))?; + Arc::clone(&plugin), config_for("demo.tool_pre_invoke"))?; manager.register_handler::( - plugin, config_for("tool_post_invoke"))?; + plugin, config_for("demo.tool_post_invoke"))?; ``` -For the **CMF pattern** — one handler covers many CMF hook *names* (`cmf.tool_pre_invoke`, `cmf.llm_input`, `cmf.llm_output`, etc.) all carrying the same `MessagePayload` — define a single `CmfHook` marker and register it under multiple names: +For the **CMF pattern**: one handler covers many CMF hook *names* (`cmf.tool_pre_invoke`, `cmf.llm_input`, `cmf.llm_output`, etc.) all carrying the same `MessagePayload`: define a single `CmfHook` marker and register it under multiple names: ```rust manager.register_handler_for_names::( @@ -717,14 +720,14 @@ manager.register_handler_for_names::( )?; ``` -This is the case where `invoke_named::("cmf.tool_pre_invoke", ...)` matters — the type pins the payload to `MessagePayload`, but the runtime hook name selects which set of plugins to fire. +This is the case where `invoke_named::("cmf.tool_pre_invoke", ...)` matters: the type pins the payload to `MessagePayload`, but the runtime hook name selects which set of plugins to fire. ### 6.7 Capability-Gated Extension Writes -Extensions visible to a plugin are filtered by its declared `capabilities`. The framework uses copy-on-write tokens for writes — the plugin clones the extensions, gets a `WriteToken` for slots it has capabilities for, and returns the modified copy. +Extensions visible to a plugin are filtered by its declared `capabilities`. The framework uses copy-on-write tokens for writes: the plugin clones the extensions, gets a `WriteToken` for slots it has capabilities for, and returns the modified copy. ```rust -use cpex_core::hooks::payload::Extensions; +use praxis_policy_core::hooks::payload::Extensions; async fn handle( &self, @@ -747,9 +750,9 @@ async fn handle( } ``` -A plugin without the capability sees `owned.http_write_token == None` and silently can't write — no runtime panic, no security violation. The token *is* the type-system enforcement of the YAML capability. +A plugin without the capability sees `owned.http_write_token == None` and silently can't write: no runtime panic, no security violation. The token *is* the type-system enforcement of the YAML capability. -**Common capabilities** (see `cpex_core::extensions` for the full list): +**Common capabilities** (see `praxis_policy_core::extensions` for the full list): | Capability | Grants | |---|---| @@ -758,21 +761,21 @@ A plugin without the capability sees `owned.http_write_token == None` and silent | `read_headers` | `HttpExtension.request_headers` (read) | | `write_headers` | `HttpExtension.response_headers` (read + write token) | | `read_classification` | `SecurityExtension.classification` (read) | -| `write_labels` | `SecurityExtension.labels` (read + monotonic write — append-only) | +| `write_labels` | `SecurityExtension.labels` (read + monotonic write: append-only) | | `read_data` / `write_data` | `SecurityExtension.data` | | `read_objects` | `SecurityExtension.objects` | -`MonotonicSet`-typed fields (like `labels`) only allow append, never removal — the executor enforces this on the post-handle merge. +`MonotonicSet`-typed fields (like `labels`) only allow append, never removal: the executor enforces this on the post-handle merge. ### 6.8 Choosing on_error `on_error` is set per-plugin in YAML or `PluginConfig`. Choose based on what failure means for the request: -- **`fail`** — security/policy plugins. Can't enforce → halt the request. -- **`ignore`** — observability plugins (audit, metrics). Failure is annoying but non-fatal. -- **`disable`** — non-essential plugins with potential to fail repeatedly (e.g., a stale external dependency). The framework auto-disables the plugin after one failure to stop log spam. +- **`fail`**: security/policy plugins. Can't enforce → halt the request. +- **`ignore`**: observability plugins (audit, metrics). Failure is annoying but non-fatal. +- **`disable`**: non-essential plugins with potential to fail repeatedly (e.g., a stale external dependency). The framework auto-disables the plugin after one failure to stop log spam. -`Ignore` and `Disable` failures are recorded in `PipelineResult.errors` — they are not silent. +`Ignore` and `Disable` failures are recorded in `PipelineResult.errors`: they are not silent. ## 7. Factories & Registration @@ -808,7 +811,7 @@ pub struct PluginInstance { } ``` -`handlers` is one entry per hook name. For a plugin that handles two hooks (`tool_pre_invoke`, `tool_post_invoke`), the factory returns a `PluginInstance` with two entries. +`handlers` is one entry per hook name. For a plugin that handles two hooks (`demo.tool_pre_invoke`, `demo.tool_post_invoke`), the factory returns a `PluginInstance` with two entries. ### 7.3 PluginFactoryRegistry @@ -824,11 +827,11 @@ impl PluginFactoryRegistry { } ``` -Populate before calling `PluginManager::from_config(path, &factories)`. The manager dispatches by `config.kind` — if the kind isn't registered, it returns `PluginError::Config { message: "unknown kind: ..." }`. +Populate before calling `PolicyEngine::from_config(path, &factories)`. The manager dispatches by `config.kind`: if the kind isn't registered, it returns `PluginError::Config { message: "unknown kind: ..." }`. ## 8. Extensions -Extensions are typed sidecar data carried alongside the payload. They are **always** a separate parameter — never inside the payload — because they need per-plugin capability filtering and independent modification. +Extensions are typed sidecar data carried alongside the payload. They are **always** a separate parameter (never inside the payload) because they need per-plugin capability filtering and independent modification. ```rust pub struct Extensions { @@ -862,7 +865,7 @@ pub struct Extensions { | `Framework` | Agentic framework context (framework name, node/graph IDs) | | `Custom` | Free-form key-value | -Each extension is held behind `Arc` so cloning the `Extensions` container is cheap — only the field mutated needs a deep clone. `OwnedExtensions` is the mutable form returned from `cow_copy()`. +Each extension is held behind `Arc` so cloning the `Extensions` container is cheap: only the field mutated needs a deep clone. `OwnedExtensions` is the mutable form returned from `cow_copy()`. For capability-gated writes, see §6.7. @@ -871,7 +874,7 @@ For capability-gated writes, see §6.7. **CMF (ContextForge Message Format)** is a typed multi-part message used by the agentic-pipeline hooks (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). The full spec is in [cmf-message-spec.md](./cmf-message-spec.md); the highlights: ```rust -use cpex_core::cmf::{Message, MessagePayload, ContentPart, Role}; +use praxis_policy_core::cmf::{Message, MessagePayload, ContentPart, Role}; use serde_json::json; let msg = MessagePayload { @@ -892,7 +895,7 @@ let msg = MessagePayload { }; ``` -`MessagePayload` already implements `PluginPayload` — no `impl_plugin_payload!` needed. +`MessagePayload` already implements `PluginPayload`: no `impl_plugin_payload!` needed. **Built-in CMF hooks** (registered when you wire CMF into the manager): @@ -902,7 +905,7 @@ let msg = MessagePayload { | `cmf.tool_post_invoke` | After tool execution | | `cmf.llm_input` | Before LLM call | | `cmf.llm_output` | After LLM response | -| `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch` | Prompt fetch lifecycle | +| `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` | Prompt fetch lifecycle | | `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` | Resource fetch lifecycle | A single plugin registers a `CmfHook` marker against multiple names with `register_handler_for_names`, then dispatches via `invoke_named::(name, ...)`. See §6.6. @@ -912,83 +915,82 @@ A single plugin registers a `CmfHook` marker against multiple names with `regist The full structure of the config file consumed by `load_config_file`: ```yaml -plugin_settings: - routing_enabled: true # turn on route resolution (vs legacy conditions) - plugin_timeout: 30 # default timeout in seconds - -global: - policies: - all: # reserved — fires on every invocation - plugins: [identity-resolver] - pii: # custom group — fires when route has "pii" tag - plugins: [pii-guard] +engine_settings: + plugin_timeout: 30 # default timeout in seconds; dispatch: policy is the default plugins: - name: identity-resolver - kind: builtin/identity # must match a registered factory key - hooks: [tool_pre_invoke, tool_post_invoke] - mode: sequential - priority: 10 + kind: demo/identity # must match a registered factory key + hooks: [identity.resolve] on_error: fail capabilities: [read_subject] - config: # opaque to framework — passed to factory + config: # opaque to framework, passed to factory strict_mode: true - name: pii-guard - kind: builtin/pii - hooks: [tool_pre_invoke] - mode: sequential - priority: 20 + kind: demo/pii + hooks: [cmf.tool_pre_invoke] on_error: fail capabilities: [read_labels, read_subject] - name: audit-logger - kind: builtin/audit - hooks: [tool_pre_invoke, tool_post_invoke] + kind: demo/audit + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] mode: fire_and_forget - priority: 100 on_error: ignore +global: + authentication: [identity-resolver] # every route resolves identity + authorization: + pre_invocation: + - "run(audit-logger)" # every route runs the audit step + +groups: + pii: # joined by routes tagged "pii" + authorization: + pre_invocation: + - "run(pii-guard)" + routes: - tool: get_compensation - meta: - tags: [pii, hr] # adds tags to MetaExtension for matching tools - plugins: - - audit-logger # route-specific override + groups: [pii] # same as meta: { tags: [pii] } + authorization: + pre_invocation: + - "require(role.hr)" - tool: list_departments - plugins: - - audit-logger + authorization: + pre_invocation: ["allow"] - - tool: "*" # wildcard — catch-all - plugins: - - audit-logger + - tool: "*" # wildcard: catch-all + authorization: + pre_invocation: ["allow"] ``` -**Routes** are evaluated in order; first match wins. The wildcard `"*"` catches anything not matched by an earlier route. `meta.tags` augments the `MetaExtension.tags` for the matched tool, which can then trigger tag-based policy groups. +**Routes** select by entity: a specific route wins over the wildcard `"*"`, which catches anything not matched by another route. `meta.tags` augments the `MetaExtension.tags` for the matched tool, which can then trigger tag-based policy groups. -**Policy groups** are named bundles of plugins. The `"all"` group is reserved and always fires. Other groups (e.g., `pii`) fire when a route's tags include the group name. +**Groups** are named policy bundles under the top-level `groups:` key. A route joins one with `groups:` or a matching tag. Always-on steps go under `global.authorization:` (the 0.2 `global.policies.all` bundle and route `plugins:` lists are load errors in 0.3). Under the default `dispatch: policy`, a plugin runs only where a step names it. Loading: ```rust let mut factories = PluginFactoryRegistry::new(); -factories.register("builtin/identity", Box::new(IdentityFactory)); -factories.register("builtin/pii", Box::new(PiiFactory)); -factories.register("builtin/audit", Box::new(AuditFactory)); +factories.register("demo/identity", Box::new(IdentityFactory)); +factories.register("demo/pii", Box::new(PiiFactory)); +factories.register("demo/audit", Box::new(AuditFactory)); -let manager = PluginManager::from_config(Path::new("plugins.yaml"), &factories)?; +let manager = PolicyEngine::from_config(Path::new("plugins.yaml"), &factories)?; manager.initialize().await?; ``` ## 11. Sample Plugin: Full Worked Example -This walks through a complete native-Rust plugin from payload definition to invocation. Source for reference: [crates/cpex-core/examples/plugin_demo.rs](../../crates/cpex-core/examples/plugin_demo.rs). +This walks through a complete native-Rust plugin from payload definition to invocation. Source for reference: [crates/ppe-core/examples/plugin_demo.rs](https://github.com/praxis-proxy/policy/blob/v0.4.1/crates/ppe-core/examples/plugin_demo.rs) in the praxis-policy repository. ### 11.1 Define the Payload ```rust -use cpex_core::impl_plugin_payload; +use praxis_policy_core::impl_plugin_payload; #[derive(Debug, Clone)] struct ToolInvokePayload { @@ -1002,20 +1004,20 @@ impl_plugin_payload!(ToolInvokePayload); ### 11.2 Define the Hook Types ```rust -use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use praxis_policy_core::hooks::trait_def::{HookTypeDef, PluginResult}; struct ToolPreInvoke; impl HookTypeDef for ToolPreInvoke { type Payload = ToolInvokePayload; type Result = PluginResult; - const NAME: &'static str = "tool_pre_invoke"; + const NAME: &'static str = "demo.tool_pre_invoke"; } struct ToolPostInvoke; impl HookTypeDef for ToolPostInvoke { type Payload = ToolInvokePayload; type Result = PluginResult; - const NAME: &'static str = "tool_post_invoke"; + const NAME: &'static str = "demo.tool_post_invoke"; } ``` @@ -1024,11 +1026,11 @@ impl HookTypeDef for ToolPostInvoke { ```rust use std::sync::Arc; use async_trait::async_trait; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::HookHandler; -use cpex_core::plugin::{Plugin, PluginConfig}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::error::{PluginError, PluginViolation}; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::hooks::trait_def::HookHandler; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; /// Plugin that requires a non-empty `user` field on every invocation. struct IdentityResolver { @@ -1086,9 +1088,9 @@ impl HookHandler for IdentityResolver { ### 11.5 Build a Factory ```rust -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::registry::AnyHookHandler; +use praxis_policy_core::factory::{PluginFactory, PluginInstance}; +use praxis_policy_core::hooks::adapter::TypedHandlerAdapter; +use praxis_policy_core::registry::AnyHookHandler; struct IdentityFactory; @@ -1099,12 +1101,12 @@ impl PluginFactory for IdentityFactory { let mut handlers: Vec<(&'static str, Arc)> = Vec::new(); for hook in &config.hooks { match hook.as_str() { - "tool_pre_invoke" => handlers.push(( - "tool_pre_invoke", + "demo.tool_pre_invoke" => handlers.push(( + "demo.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), )), - "tool_post_invoke" => handlers.push(( - "tool_post_invoke", + "demo.tool_post_invoke" => handlers.push(( + "demo.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), )), other => return Err(PluginError::Config { @@ -1126,18 +1128,18 @@ impl PluginFactory for IdentityFactory { Use `register_handler::` for compile-time dispatch and `invoke::` for the typed call path. The compiler enforces that the payload you pass matches `H::Payload`. ```rust -use cpex_core::manager::{PluginManager, ManagerConfig}; -use cpex_core::plugin::{PluginConfig, PluginMode, OnError}; +use praxis_policy_core::engine::{PolicyEngine, PolicyEngineConfig}; +use praxis_policy_core::plugin::{PluginConfig, PluginMode, OnError}; #[tokio::main] async fn main() -> Result<(), Box> { - let manager = PluginManager::new(ManagerConfig::default()); + let manager = PolicyEngine::new(PolicyEngineConfig::default()); - // Programmatic registration — skips the factory entirely. + // Programmatic registration: skips the factory entirely. let cfg = PluginConfig { name: "identity-resolver".into(), kind: "builtin/identity".into(), - hooks: vec!["tool_pre_invoke".into()], + hooks: vec!["demo.tool_pre_invoke".into()], mode: PluginMode::Sequential, on_error: OnError::Fail, ..Default::default() @@ -1147,7 +1149,7 @@ async fn main() -> Result<(), Box> { manager.initialize().await?; - // Typed invoke — payload type must be ToolPreInvoke::Payload (= ToolInvokePayload). + // Typed invoke: payload type must be ToolPreInvoke::Payload (= ToolInvokePayload). let payload = ToolInvokePayload { tool_name: "get_compensation".into(), user: "alice".into(), @@ -1205,7 +1207,7 @@ The first invoke takes `None`; subsequent invokes within the same logical reques For YAML-driven registration, register the factory and call `from_config`: ```rust -use cpex_core::factory::PluginFactoryRegistry; +use praxis_policy_core::factory::PluginFactoryRegistry; use std::path::Path; #[tokio::main] @@ -1213,7 +1215,7 @@ async fn main() -> Result<(), Box> { let mut factories = PluginFactoryRegistry::new(); factories.register("builtin/identity", Box::new(IdentityFactory)); - let manager = PluginManager::from_config( + let manager = PolicyEngine::from_config( Path::new("plugins.yaml"), &factories, )?; @@ -1241,10 +1243,10 @@ pub struct PluginContext { | Store | Scope | Use case | |---|---|---| -| `local_state` | Plugin-private, persists across multiple hooks within one request (`tool_pre_invoke` → `tool_post_invoke`) | Stash per-request data the plugin will need on the corresponding post-hook (e.g., a timer started in pre, stopped in post) | +| `local_state` | Plugin-private, persists across multiple hooks within one request (`demo.tool_pre_invoke` → `demo.tool_post_invoke`) | Stash per-request data the plugin will need on the corresponding post-hook (e.g., a timer started in pre, stopped in post) | | `global_state` | Shared across plugins within one invoke chain | Pass data from one plugin to another (e.g., identity-resolver populates `user_id`, downstream plugins read it) | -Threading `local_state` across hooks is the entire reason `PluginContextTable` exists — the embedder threads the returned context table from one invoke into the next, and the framework hydrates each plugin's `local_state` from the table. +Threading `local_state` across hooks is the entire reason `PluginContextTable` exists: the embedder threads the returned context table from one invoke into the next, and the framework hydrates each plugin's `local_state` from the table. `global_state` is committed back to a canonical store after each plugin runs (in Sequential phase) so the next plugin sees the merged view. @@ -1258,7 +1260,7 @@ The framework surfaces failures through three channels, each with distinct seman | `PipelineResult.violation: Option` | A plugin called `PluginResult::deny(...)` | Set when `result.is_denied() == true`; `result.continue_processing == false` | | `PipelineResult.errors: Vec` | Plugin returned `Err` with `on_error: ignore` or `on_error: disable`; plugin timeout in non-blocking phase; FFI-layer issues | Soft-error log; pipeline still completed | -Note: invoke methods (`invoke`, `invoke_named`, `invoke_by_name`) do **not** return `Result`. They always return `(PipelineResult, BackgroundTasks)`. All in-pipeline failures land in the channels above. This is deliberate — once you've reached invoke, "the framework couldn't run anything" isn't a possible state; either no plugins matched (and you get an `allow` result) or the pipeline ran and produced a result. +Note: invoke methods (`invoke`, `invoke_named`, `invoke_by_name`) do **not** return `Result`. They always return `(PipelineResult, BackgroundTasks)`. All in-pipeline failures land in the channels above. This is deliberate: once you've reached invoke, "the framework couldn't run anything" isn't a possible state; either no plugins matched (and you get an `allow` result) or the pipeline ran and produced a result. ```rust let (result, _bg) = mgr.invoke::(payload, ext, ct).await; @@ -1269,7 +1271,7 @@ if !result.continue_processing { return; // halt downstream work } -// Soft errors — pipeline ran, but some plugins failed non-fatally. +// Soft errors: pipeline ran, but some plugins failed non-fatally. for record in &result.errors { log::warn!( "plugin {} failed: {} ({})", @@ -1281,23 +1283,23 @@ for record in &result.errors { ## 14. Threading & Async -- `PluginManager` is `Send + Sync`. Use `Arc` and call `invoke::(&self, ...)` from many tasks concurrently. The internal registry uses `ArcSwap` for lock-free reads; mutations (registration, config load) clone-and-swap. +- `PolicyEngine` is `Send + Sync`. Use `Arc` and call `invoke::(&self, ...)` from many tasks concurrently. The internal registry uses `ArcSwap` for lock-free reads; mutations (registration, config load) clone-and-swap. - Plugins must be `Send + Sync` (enforced by the `Plugin` trait bound). All plugin state shared via `&self` must be safe for concurrent access. -- `HookHandler::handle` is `async fn`. Plugins that don't need to await compile to a ready future with no observable cost; plugins that need to await per-invocation just use `.await`. Prefer caching state in `Plugin::initialize` and reading from cache on the hot path — `.await` in `handle` adds latency to every request. Never call `block_on` inside `handle`; the manager already runs you on a tokio task and nested blocking will panic. -- The framework runs Concurrent-phase handlers in a `tokio::task::JoinSet` — true parallelism if your plugins are CPU-bound. +- `HookHandler::handle` is `async fn`. Plugins that don't need to await compile to a ready future with no observable cost; plugins that need to await per-invocation just use `.await`. Prefer caching state in `Plugin::initialize` and reading from cache on the hot path: `.await` in `handle` adds latency to every request. Never call `block_on` inside `handle`; the manager already runs you on a tokio task and nested blocking will panic. +- The framework runs Concurrent-phase handlers in a `tokio::task::JoinSet`: true parallelism if your plugins are CPU-bound. - When embedded via cpex-ffi, all managers in the process share **one** tokio runtime. Worker thread count is configurable; see [cpex-go-spec.md](./cpex-go-spec.md) §5.9 for the FFI-side knobs. Within pure Rust, you control the runtime yourself (`#[tokio::main]` or manual `Runtime::new()`). ## 15. Testing Plugins -Native Rust plugins are easy to unit-test — instantiate the plugin, build a `PluginContext`, call `handle` directly without going through the manager. +Native Rust plugins are easy to unit-test: instantiate the plugin, build a `PluginContext`, call `handle` directly without going through the manager. ```rust #[cfg(test)] mod tests { use super::*; - use cpex_core::context::PluginContext; - use cpex_core::hooks::payload::Extensions; - use cpex_core::plugin::PluginId; + use praxis_policy_core::context::PluginContext; + use praxis_policy_core::hooks::payload::Extensions; + use praxis_policy_core::plugin::PluginId; #[tokio::test] async fn rejects_empty_user() { @@ -1318,7 +1320,7 @@ mod tests { } ``` -For integration tests through the full pipeline, build a `PluginManager`, register the plugin, and `invoke::`. The `cpex-core` test suite has examples in `crates/cpex-core/src/manager.rs` (test module). +For integration tests through the full pipeline, build a `PolicyEngine`, register the plugin, and `invoke::`. The engine's test suite has examples in `crates/ppe-core/src/engine.rs` (test module) in the praxis-policy repository. ## 16. Build & Test @@ -1331,19 +1333,20 @@ The repo Makefile is the canonical interface: | `make rust-test-ffi` | Only the cpex-ffi crate tests (faster iteration) | | `make rust-lint-check` | Read-only `cargo fmt --check` + `cargo clippy -- -D warnings` | | `make rust-lint` (or `rust-lint-fix`) | Mutating: `cargo fmt` + `clippy --fix` | -| `make examples-build` | Build all examples — catches stale public-API usage | +| `make examples-build` | Build all examples: catches stale public-API usage | | `make examples-run` | Build + run each example end-to-end | | `make ci` | Full CI gate (lint-check + test-all + examples-build) | Raw commands: ```bash -# Build cpex-core -cargo build -p cpex-core +# Build the facade (the engine comes from crates.io) +cargo build -p cpex # Run example -cargo run --example plugin_demo -p cpex-core -cargo run --example cmf_capabilities_demo -p cpex-core +# The engine's examples live in the praxis-policy repository: +# cargo run --example plugin_demo -p praxis-policy-core +# cargo run --example cmf_capabilities_demo -p praxis-policy-core # Tests cargo test --workspace @@ -1351,7 +1354,7 @@ cargo test --workspace ## 17. Dynamic Plugin Loading (Design Note) -> **Status:** the C ABI path described below is shipped today (cpex-ffi, the Go integration). The Rust `cdylib` path is design-only — see §18 row "Native (`dlopen`) plugin loader." +> **Status:** the C ABI path described below is shipped today (cpex-ffi, the Go integration). The Rust `cdylib` path is design-only: see §18 row "Native (`dlopen`) plugin loader." The framework is built so dynamic plugins (loaded at runtime from a `.so` / `.dylib` / `.dll`) work without changing the typed plugin-author API. The architecture deliberately separates two layers: @@ -1363,14 +1366,14 @@ AnyHookHandler ← object-safe; #[async_trait] boxes the future Arc ← THIS is what crosses module boundaries ``` -The typed `HookHandler` is non-object-safe (because of `impl Future` return-position) — you can't have `Box>` and you definitely can't put one across `dlopen`. That's intentional. The plugin compiles its own `TypedHandlerAdapter` and erases to `Arc` *inside its own binary* before handing anything to the host. The host only ever sees `dyn AnyHookHandler`, which has a stable vtable. +The typed `HookHandler` is non-object-safe (because of `impl Future` return-position): you can't have `Box>` and you definitely can't put one across `dlopen`. That's intentional. The plugin compiles its own `TypedHandlerAdapter` and erases to `Arc` *inside its own binary* before handing anything to the host. The host only ever sees `dyn AnyHookHandler`, which has a stable vtable. ### 17.1 Two transport strategies | Strategy | Status | What crosses the boundary | |---|---|---| -| **C ABI via cpex-ffi** | Shipped (Go integration) | `extern "C"` functions, opaque manager handles, MessagePack-encoded payloads. Plugins never touch `HookHandler` directly — they implement whatever the FFI shim exposes. See [cpex-go-spec.md](./cpex-go-spec.md). | -| **Rust `cdylib` via `dlopen`** | Not implemented | A `cdylib` exports a registration entry point that returns `Arc` (or a vec of named handlers). Host loads via `libloading` and registers via `PluginManager::register_raw`. | +| **C ABI via cpex-ffi** | Shipped (Go integration) | `extern "C"` functions, opaque manager handles, MessagePack-encoded payloads. Plugins never touch `HookHandler` directly: they implement whatever the FFI shim exposes. See [cpex-go-spec.md](./cpex-go-spec.md). | +| **Rust `cdylib` via `dlopen`** | Not implemented | A `cdylib` exports a registration entry point that returns `Arc` (or a vec of named handlers). Host loads via `libloading` and registers via `PolicyEngine::register_raw`. | ### 17.2 Async stays end-to-end @@ -1390,8 +1393,8 @@ plugin: handle(...).await // plugin can await JWKS, RPC, anything Independent of which transport you pick: -- **Shared runtime.** The plugin's future doesn't carry its own runtime — it gets driven by whichever tokio runtime the host is awaiting on. In the cpex-ffi path that's the process-shared runtime; in a Rust-cdylib path it'd be whatever the host has running. Plugins must not spawn or own a runtime themselves. -- **No nested `block_on`.** A dynamic plugin must never `block_on` inside `handle` — the future is already running on a tokio task and nested blocking will panic. Same rule as in-tree plugins, but easier to forget when the plugin lives in someone else's repo. +- **Shared runtime.** The plugin's future doesn't carry its own runtime: it gets driven by whichever tokio runtime the host is awaiting on. In the cpex-ffi path that's the process-shared runtime; in a Rust-cdylib path it'd be whatever the host has running. Plugins must not spawn or own a runtime themselves. +- **No nested `block_on`.** A dynamic plugin must never `block_on` inside `handle`: the future is already running on a tokio task and nested blocking will panic. Same rule as in-tree plugins, but easier to forget when the plugin lives in someone else's repo. - **Panic isolation.** The host wraps every `AnyHookHandler::invoke` call in `catch_unwind`. cpex-ffi already does this at the C boundary; a Rust `cdylib` host would do the same at the registration shim. Specific to the Rust `cdylib` path: @@ -1402,18 +1405,18 @@ Specific to the Rust `cdylib` path: ### 17.4 Why this works without changing the typed API -The handler-collapse work in §6.2 (single async `HookHandler` trait) is orthogonal to dynamic loading. AFIT lives at the typed layer (inside the plugin's own binary); the module boundary lives at the type-erased layer. They don't collide. Plugin authors writing native, FFI, or hypothetical-cdylib plugins all write the same `async fn handle(...)` against the same `HookHandler` trait — only the registration shim changes between transports. +The handler-collapse work in §6.2 (single async `HookHandler` trait) is orthogonal to dynamic loading. AFIT lives at the typed layer (inside the plugin's own binary); the module boundary lives at the type-erased layer. They don't collide. Plugin authors writing native, FFI, or hypothetical-cdylib plugins all write the same `async fn handle(...)` against the same `HookHandler` trait: only the registration shim changes between transports. ## 18. Gaps and Unimplemented Features | Feature | Python Location | Status in Rust | |---|---|---| -| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented — no single-plugin invoke | -| `HookPayloadPolicy` (field-level write control) | `hooks/policies.py` | Not implemented — capabilities are slot-level, not field-level | -| Programmatic capability rebinding per-invoke | `extensions/tiers.py` | Not implemented — capabilities are config-level only | -| `TenantPluginManager` (multi-tenant in one manager) | `manager.py` | Not implemented — one manager per tenant (shared runtime caps total threads when via FFI) | -| Observability provider injection | `manager.py` | Not implemented — observability via `tracing` crate | -| `reset()` (reinitialize without restart) | `manager.py` | Not implemented — shutdown and recreate | +| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented: no single-plugin invoke | +| `HookPayloadPolicy` (field-level write control) | `hooks/policies.py` | Not implemented: capabilities are slot-level, not field-level | +| Programmatic capability rebinding per-invoke | `extensions/tiers.py` | Not implemented: capabilities are config-level only | +| `TenantPolicyEngine` (multi-tenant in one manager) | `manager.py` | Not implemented: one manager per tenant (shared runtime caps total threads when via FFI) | +| Observability provider injection | `manager.py` | Not implemented: observability via `tracing` crate | +| `reset()` (reinitialize without restart) | `manager.py` | Not implemented: shutdown and recreate | | External plugin transports (gRPC/Unix/MCP) | `framework/external/` | Not yet implemented | | Isolated (subprocess) plugins | `framework/isolated/` | Not yet implemented | | PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet implemented | @@ -1421,4 +1424,4 @@ The handler-collapse work in §6.2 (single async `HookHandler` trait) is orth | Native (`dlopen`) plugin loader | `cpex-hosts::native` (planned) | Not yet implemented | | `retry_delay_ms` in `PipelineResult` | `models.py` | Not implemented | -The `cpex_core::plugin::Plugin` trait doc-comment mentions `cpex-hosts::{wasm,python,native}` host crates that would bridge to non-Rust plugin runtimes. None exist yet — this is a design intent placeholder, not shipped functionality. +The `praxis_policy_core::plugin::Plugin` trait doc-comment mentions `cpex-hosts::{wasm,python,native}` host crates that would bridge to non-Rust plugin runtimes. None exist yet: this is a design intent placeholder, not shipped functionality. diff --git a/docs/specs/extensions-wire-contract.md b/docs/specs/extensions-wire-contract.md index 592be4cb..dc5ded23 100644 --- a/docs/specs/extensions-wire-contract.md +++ b/docs/specs/extensions-wire-contract.md @@ -1,11 +1,11 @@ # Extensions wire contract (out-of-process Python plugins) Status: both sides implemented; **the two have now been exercised against each -other** — once, from a hand-provisioned venv, not yet in CI. +other**: once, from a hand-provisioned venv, not yet in CI. -- **Rust host** — `feat/python_plugin_compat_2`, +- **Rust host**: `feat/python_plugin_compat_2`, `crates/cpex-hosts-python/src/extensions.rs`. -- **Python worker** — `feat/python_plugin_compat_0.1.x`, +- **Python worker**: `feat/python_plugin_compat_0.1.x`, `cpex/framework/isolated/worker.py`. **Not yet released**; see "Reproducing the cross-surface run" for how it has to be provisioned into a plugin venv. @@ -15,16 +15,23 @@ Real round-trip coverage landed in `Extensions` cross into an installed plugin's worker subprocess through the installer-written `plugins/config.yaml`, and the hook runs. That closed item 2 of the old Remaining work and **overturned the two claims this document made about -the `http` divergence's failure mode** — see "Known divergence: the http slot", +the `http` divergence's failure mode**: see "Known divergence: the http slot", which is still the one place the shapes do not line up, but which fails differently and more quietly than recorded here before. Plan: `docs/plans/2026-07-29-003-feat-out-of-process-extensions-delivery-plan.md` +**0.3.0 note.** The engine side of this contract now lives in the praxis-policy +engine (PPE) 0.4.1; `crates/ppe-core/...` paths below are in the +[praxis-policy repository](https://github.com/praxis-proxy/policy/tree/v0.4.1). +The host translates PPE hook names (`cmf.tool_pre_invoke`, `identity.resolve`, +`token.delegate`) to the names the Python worker registers; see the +[Python host README](../../crates/cpex-hosts-python/README.md#hooks-and-payloads). + **Relationship to the CMF spec.** `docs/specs/cmf-message-spec.md` §3 is the normative definition of the extension slots, their mutability tiers, and their field shapes. This document does not restate it and is not a substitute for it; -it covers only what §3 leaves open — how those slots are framed on a JSON stdio +it covers only what §3 leaves open: how those slots are framed on a JSON stdio wire between two processes (field names, omission semantics, header scrubbing, what "no change" looks like on return). Read §3 first for *what* an extension is; read this for *how it crosses a process boundary*. @@ -32,22 +39,22 @@ is; read this for *how it crosses a process boundary*. Where the two disagree, each disagreement is called out inline below rather than resolved silently. There are three: the `delegation` and `raw_credentials` slots (present in the implementation, absent from §3), the `http` slot's field shape -(§3.5 matches Python, not Rust), and the sensitive-header list (four names here, -three in §3.5). None is a case of this wire deviating from a shape §3 pins down -— they are places §3 has not caught up to the implementation, or where this -transport strips more than the spec's floor requires. +(§3.5 matches Python, not Rust), and the outbound raw-header policy (empty +maps by default, stricter than §3.5's named-header strip). The first two are +places §3 has not caught up to the implementation; the last is a stricter +transport rule for unvalidated header values. This document pins the contract the two surfaces share, and records the answers to the plan's Open Questions as resolved against the actual code. It exists -because the two sides land on different branches and ship separately — the worker -as a Python package into each plugin's venv, the host as this crate — so neither +because the two sides land on different branches and ship separately: the worker +as a Python package into each plugin's venv, the host as this crate: so neither side's source can serve as the other's reference, and a build in hand cannot be trusted to be the build this contract describes (see the version collision under "Reproducing the cross-surface run"). Claims here are verified against running code where they can be. Where an earlier revision recorded an inferred behaviour that turned out to be wrong, the -correction is called out as a correction rather than quietly replaced — the +correction is called out as a correction rather than quietly replaced: the `http` failure mode was misdescribed for a while precisely because it looked settled. @@ -61,7 +68,7 @@ settled. The two names differ deliberately. The return field is **not** `extensions` because the worker's response is a serialized `PluginResult`, and that model already carries `modified_extensions: Optional[Extensions]` -(`cpex/framework/models.py`) — the same field the in-process Python manager +(`cpex/framework/models.py`): the same field the in-process Python manager accumulates in `cpex/framework/manager.py`. Reusing it means an out-of-process plugin returns extensions exactly the way its in-process equivalent does, and the worker does not have to invent a second field. @@ -82,18 +89,18 @@ every gated slot the first one allowed. That composition is worth stating plainly, because both filters are individually correct and the result was a silently dead channel: a plugin declaring `read_labels` + `read_headers` received `security.labels` empty and `http` as -`None` — byte-identical to a plugin that declared nothing. Sending the +`None`: byte-identical to a plugin that declared nothing. Sending the capabilities gives both filters the same input, which makes the second idempotent. **Only names the Python `Capability` enum models may be forwarded.** `PluginConfig`'s `capabilities` validator *raises* on an unknown name, and it -runs while the worker constructs the config — before the plugin is called — so +runs while the worker constructs the config (before the plugin is called) so one host-only name costs every hook of that plugin, not just the slot it gates. The host's vocabulary is the superset today: `read_all`, `read_client`, `read_workload`, `read_inbound_credentials`, and `read_delegated_tokens` have no Python counterpart and are dropped with a warning. Dropping is safe in the -direction that matters — the extensions were already filtered host-side, so a +direction that matters: the extensions were already filtered host-side, so a dropped name can only narrow the worker's view, never widen it. Rust: `WORKER_KNOWN_CAPABILITIES` and @@ -107,14 +114,14 @@ Every field in this contract is optional on the wire in the sense that matters for safety: an older `worker.py` does not *reject* a field it does not model, it ignores it. So a host that simply sends `credential` or `extensions` cannot tell delivery from silent discard, and a plugin declaring `read_inbound_credentials` -against such a worker ran with **no credential at all** — the exact outcome +against such a worker ran with **no credential at all**: the exact outcome fail-closed rule 2 exists to prevent. Version strings cannot settle it either: two framework builds shipped as `0.1.2` with different worker protocols. The worker is therefore asked directly, once per spawn, before any plugin code loads. -**Request** — task type `capabilities`, no other fields: +**Request**: task type `capabilities`, no other fields: ```json {"task_type": "capabilities", "request_id": ""} @@ -132,14 +139,14 @@ loads. |---|---| | `protocol_version` | Worker's declared wire-protocol version; `0` if absent | | `features` | Feature names the worker **implements**. The contract. | -| `cpex_version` | Installed distribution version — **diagnostics only** | +| `cpex_version` | Installed distribution version: **diagnostics only** | `features` is what the host gates on; `cpex_version` is never a decision input, for the 0.1.2 reason above. A worker predating this task type falls through its dispatch to `{"status": "error", "message": "task type not supported."}`. The host reads that -specific reply as **"no features"** rather than as a transport fault — the +specific reply as **"no features"** rather than as a transport fault: the conservative answer. Every other failure (timeout, dead worker, malformed frame) stays an error, because those mean the probe did not complete and reporting "old worker" for a current one would be a wrong diagnosis. @@ -151,13 +158,13 @@ a plugin's own declaration commits it to using: |---|---| | `read_inbound_credentials` or `read_delegated_tokens` | `credential` | | any capability in `WORKER_KNOWN_CAPABILITIES` | `extensions` | -| nothing gated | *none* — starts on any worker | +| nothing gated | *none*: starts on any worker | A mismatch fails the plugin at `initialize()` with a `Credential` error naming the missing feature and what the worker did report. Startup, not per-request: the plugin cannot be served safely at all, and leaving it registered defers the discovery to a live request. The respawn path re-verifies rather than trusting -the original handshake — the replacement resolves `worker.py` from the venv +the original handshake: the replacement resolves `worker.py` from the venv again, and the venv can have been rebuilt or downgraded since. Adding a name to `WORKER_FEATURES` in `worker.py` asserts the code path exists; @@ -175,8 +182,8 @@ The two sides live in different repositories, so `credential_e2e.rs::the_real_worker_answers_the_capabilities_handshake` drives a real credential hook through a real worker: it asserts the token *arrives*, not merely that the feature was claimed. A worker could otherwise report a feature it -does not implement, and the unit tests — which use a stub with a hardcoded -feature list — would not notice. +does not implement, and the unit tests: which use a stub with a hardcoded +feature list: would not notice. ## Slots carried @@ -185,7 +192,7 @@ feature list — would not notice. Each present slot serializes to the JSON of its own extension sub-model; absent slots are omitted. The Rust capability-filtered view is the source of truth for -which slots are present inbound — an absent slot means the plugin's capabilities +which slots are present inbound: an absent slot means the plugin's capabilities excluded it. **`raw_credentials` is never carried.** Its token fields are `#[serde(skip)]` so @@ -196,68 +203,56 @@ capability-gated `credential` DTO (`credentials.rs`) instead. Note: the plan's slot list omitted `delegation`, **and so does CMF §3.** The extension tree in that section lists eleven slots and has no `delegation` entry (nor a `3.x` subsection for it), but it exists in the Rust container -(`crates/cpex-core/src/extensions/container.rs:64`), is monotonic, is gated by +(`crates/ppe-core/src/extensions/container.rs:64`), is monotonic, is gated by `append_delegation`, and is carried on this wire. `raw_credentials` is likewise absent from CMF §3 and present in the container. So the twelve slots above are the *implementation's* set, not §3's. Where this -document and CMF §3 disagree on which slots exist, CMF §3 is behind — it is the +document and CMF §3 disagree on which slots exist, CMF §3 is behind: it is the older document and has not been updated for these two. That is a gap in §3 to close, not a divergence this contract is entitled to resolve by itself; treat §3 as authoritative on the shape and tiers of the slots it *does* describe, and this document as authoritative on what actually crosses the wire. -## Sensitive headers - -`Authorization`, `Cookie`, `Set-Cookie`, and `X-API-Key` are stripped **in both -directions**, matched case-insensitively (HTTP header names are case-insensitive, -and `HttpExtension`'s own accessors look up that way, so a case-sensitive compare -would pass `authorization` straight through). - -Source of truth: `SENSITIVE_HEADERS` in -`crates/cpex-hosts-python/src/extensions.rs`, and `SENSITIVE_HEADERS` in -`cpex/framework/isolated/worker.py`. - -Every header map on the slot is scrubbed, not just a request one: a response map -can carry a `Set-Cookie` or an upstream `Authorization` echo just as a request map -carries the inbound credential. Which maps exist depends on the language — see -below. - -**This is four names, where CMF §3.5 names three.** The deliberate widening is -`Set-Cookie`, and it is a widening of what gets *stripped* — strictly more -redaction than the spec floor, never less, so it cannot make the channel leakier -than CMF permits. It is here because `response_headers` exists on the Rust slot -and a response map's whole job is carrying `Set-Cookie`; stripping `Cookie` -inbound while forwarding `Set-Cookie` outbound would redact one direction of the -same session credential. - -(An earlier revision of this document claimed the opposite — that only the -spec's three were stripped and `Set-Cookie` was deliberately excluded. That was -true of the code when it was written and is no longer; `extensions.rs` carries -`set-cookie` in the list and a test asserts it. Recorded as a correction rather -than silently replaced, per this document's convention.) - -CMF §3.5 is the floor, not the ceiling: it says sensitive headers "are stripped -when serialized for external policy engines" and names three. Any further name -added here must be added to *both* surfaces' `SENSITIVE_HEADERS` in the same -change, or the two directions disagree. +## Raw header boundary + +The Rust host sends empty `http.request_headers` and `http.response_headers` +maps to the worker by default, even when the plugin has `read_headers`. A +header name alone cannot establish that its value contains no credential. +`credential_payloads::is_public_header` is the approval point; a future +exception needs a strict value validator and a documented use. The same rule +applies to the public `IdentityPayload.headers` map. The source header, +selected token, and shared `CREDENTIAL_HEADERS` list remain extra checks. + +The gated `credential` object is the path for a plugin with +`read_inbound_credentials` to receive the full identity header map. Request-line +fields on the `http` extension still cross without raw header values. + +On worker return, the Rust host removes known credential header names +case-insensitively from both maps and the executor requires `write_headers` to +apply other edits. The worker's own `SENSITIVE_HEADERS` filter remains a second +return-path check. The Rust list is `CREDENTIAL_HEADERS` in +`crates/cpex-hosts-python/src/credential_payloads.rs`; it also includes +`proxy-authorization` and common gateway token header names. CMF §3.5 lists +three sensitive names as a floor for external serialization. The host's empty +outbound maps are stricter. ## Known divergence: the http slot **This is the one slot whose shape differs across the two surfaces.** The branches -have now met, and this is the one thing that did not line up — but it did not +have now met, and this is the one thing that did not line up: but it did not announce itself, which is the part worth reading below. | | Header fields | Other fields | |---|---|---| | CMF §3.5 (the spec) | `headers` (single map) | none | -| Rust `HttpExtension` (`crates/cpex-core/src/extensions/http.rs`) | `request_headers`, `response_headers` | `method`, `path`, `host`, `scheme` | +| Rust `HttpExtension` (`crates/ppe-core/src/extensions/http.rs`) | `request_headers`, `response_headers` | `method`, `path`, `host`, `scheme` | | Python `HttpExtension` (`cpex/framework/extensions/http.py`) | `headers` (single map) | none | **The spec sides with Python.** CMF §3.5 declares exactly one attribute, `headers: dict[str, str]`, and none of the request-line fields. So this is not a symmetric "two implementations drifted" problem: Python matches the spec, and -**Rust is the side that diverged from it** — it split the map in two and added +**Rust is the side that diverged from it**: it split the map in two and added `method` / `path` / `host` / `scheme` (the latter four shipped in 0.2.1 as the HTTP request-line attributes, which CMF §3.5 was never updated to describe). @@ -265,13 +260,15 @@ That matters for Remaining work item 1: "pick one shape and adapt the other" is not a coin flip. Adopting Python's shape means Rust loses `response_headers` and the four request-line attributes that `http.*` APL predicates already read, so the realistic resolution is to update CMF §3.5 to the Rust shape and map Python -up to it — not the reverse. That is a spec change and needs the CMF owners, which +up to it: not the reverse. That is a spec change and needs the CMF owners, which is why it is not resolved here. -Consequences, both currently unresolved by design rather than by accident: +Consequences of the shape mismatch: -- **The slot crosses, validates, and arrives empty — silently, in both - directions.** This is a correction: this document previously said a +- **The slot crosses and validates, but Python's `headers` map is empty.** + Empty outbound header maps are now intentional. A worker-returned header edit + can still be lost because the Rust and Python field names differ. This is a + correction: this document previously said a Rust-serialized `http` slot does *not* validate into the Python model, and that `reconstruct_extensions` drops it with a warning. Both claims are wrong, and the real behaviour is worse because it is quiet. @@ -283,38 +280,35 @@ Consequences, both currently unresolved by design rather than by accident: | Direction | On the wire | Deserializes to | Warning? | |---|---|---|---| - | Rust → Python | `{"request_headers": {"X-Request-Id": "r1"}}` | `HttpExtension(headers={})` | none | + | Rust → Python | `{"request_headers": {}}` | `HttpExtension(headers={})` | none | | Python → Rust | `{"headers": {"X-Fine": "yes"}}` | `HttpExtension { request_headers: {}, response_headers: {} }` | none | Verified empirically against the branch-built worker described under "Reproducing the cross-surface run" and against - `cpex_core::extensions::HttpExtension`, not inferred from the models. + `praxis_policy_core::extensions::HttpExtension`, not inferred from the models. Two things follow. First, `reconstruct_extensions` calls `Extensions.model_validate(known)` on the **whole object at once**, so had the slot genuinely failed validation the entire reconstruction would have returned - `None` and the plugin would have seen *no extensions at all* — not "no `http`, + `None` and the plugin would have seen *no extensions at all*: not "no `http`, but the other eleven". The per-slot degradation this document implied does not exist on that path. Second, what actually happens instead is the **hollow slot** this contract explicitly rejects for `raw_credentials`: a plugin reading - `extensions.http.headers` gets `{}` and cannot distinguish "this request had no - headers" from "the headers did not cross". A dropped slot would at least have - been honest. + `extensions.http.headers` gets `{}`. That is the deliberate outbound policy, + although the model does not tell the plugin why the map is empty. On return, + the mismatched field names can still silently discard a permitted edit. - **A mapping is still needed on one side before the http slot works across the - boundary.** The other eleven slots line up. + **A mapping is still needed for worker-returned header edits.** The other + eleven slots line up. -- The stripping code on each side is written against whichever fields the model - actually declares (`_HTTP_HEADER_FIELDS` in `worker.py`, `sanitize_http` in - `extensions.rs`), so neither leaks if the shapes change. The Python helper reads - `model_fields` and scrubs every header map it finds, so it keeps working if the - Python model later gains the split shape. +- The Rust host's outbound approval runs before serialization, against its + actual header maps, so no unvalidated header value reaches the worker even + though the models disagree. The Python worker's return-path scrub still reads + `model_fields`; the Rust host applies its own known-name filter after parsing. - Note this is why the divergence is a correctness bug and not a security one: the - sensitive-header strip runs *before* serialization, against the fields the - sending model really has, so `Authorization` never reaches the wire regardless - of which shape the receiver expects. `config_e2e.rs` asserts exactly that — no - credential header value appears anywhere in the serialized task JSON. + The shape divergence is a correctness bug for header edits, not a raw-header + exposure on the outbound path. `config_e2e.rs` checks that credential header + values do not appear in the serialized task JSON. Note: the plan referred to `http.headers`, which is right for Python and wrong for Rust. Both are recorded above rather than picking one. @@ -322,10 +316,10 @@ Rust. Both are recorded above rather than picking one. ## Mutability tiers on return Enforced by the Rust executor's copy-on-write merge -(`crates/cpex-core/src/executor.rs`), not by the wire format. The host feeds that +(`crates/ppe-core/src/executor.rs`), not by the wire format. The host feeds that merge; it implements no tier logic. -Tiers themselves are defined by CMF §3.1 and assigned per slot by §3.2 — this +Tiers themselves are defined by CMF §3.1 and assigned per slot by §3.2: this table is not a second source for them. It restates the assignment only to attach the two columns §3 does not have (the concrete capability string the executor checks, and what the executor does to a *returned* slot on this wire), and it @@ -334,16 +328,16 @@ wins and this table is stale. | Slot | Tier | Capability | Behavior | |---|---|---|---| -| `request`, `agent`, `mcp`, `completion`, `provenance`, `llm`, `framework`, `meta` | Immutable | — | Inbound `Arc` reused; a returned edit is dropped | +| `request`, `agent`, `mcp`, `completion`, `provenance`, `llm`, `framework`, `meta` | Immutable | none | Inbound `Arc` reused; a returned edit is dropped | | `security` (labels) | Monotonic | `append_labels` | Additive only; executor rejects the return if any label is removed | | `delegation` | Monotonic | `append_delegation` | Additive only | | `http` | Guarded | `write_headers` | Applied only with the capability | -| `custom` | Mutable | — | Applied as-is | +| `custom` | Mutable | none | Applied as-is | Two structural properties the worker side should know about: 1. **Immutable slots reuse the inbound `Arc`.** The executor validates the - immutable tier with `Arc::ptr_eq` — pointer identity, not value equality. A + immutable tier with `Arc::ptr_eq`: pointer identity, not value equality. A JSON round trip allocates a fresh `Arc` per slot, so deserializing the whole object from the wire would fail that check on *every* immutable slot and get the entire return rejected, even for a plugin that only touched `custom`. The @@ -355,13 +349,13 @@ Two structural properties the worker side should know about: 2. **Write authority is a token, not a claim on the wire.** The executor mints a `WriteToken` per plugin from its declared capabilities and carries it on the - inbound view. `WriteToken::new()` is `pub(crate)` to `cpex-core`, so the host + inbound view. `WriteToken::new()` is `pub(crate)` to `praxis-policy-core`, so the host cannot mint one and a token can never be forged out of worker JSON. A gated write with no token behind it is dropped. ## "No change" on return -**Omit the `modified_extensions` field** (or send `null` — the host treats both +**Omit the `modified_extensions` field** (or send `null`: the host treats both identically). This was the plan's second Open Question, and the answer is forced by @@ -372,7 +366,7 @@ changed". Pydantic emits unset Optionals as `null`, so a plugin that touched nothing still sends every key. An object whose writable slots are all `null` is therefore also -treated as no modification — the host returns `None` rather than handing the +treated as no modification: the host returns `None` rather than handing the executor a no-op merge to validate. ### Do not read `PipelineResult.modified_extensions` as "something changed" @@ -381,7 +375,7 @@ The host's `None` above is a statement about the *worker's return value*. It doe not survive into the pipeline's result as a `None`, and callers writing assertions against this channel get this backwards easily enough that it is worth pinning. -`PipelineResult::allowed_with` (`crates/cpex-core/src/executor.rs`) *always* +`PipelineResult::allowed_with` (`crates/ppe-core/src/executor.rs`) *always* populates `modified_extensions: Some(extensions)` with the pipeline's final view, whether or not any plugin wrote anything. So on an allowed pipeline the field is never `None`, and its presence carries no information. @@ -404,8 +398,9 @@ inbound one, as `config_e2e.rs` does. - **Per-slot sub-field mapping.** Serialize through each extension's own serde/pydantic model rather than a hand-written mapping, so the shape tracks the - structs as they evolve. Only `http` is rewritten, to strip sensitive headers. - Eleven of the twelve slots line up 1:1; `http` does not — see "Known divergence" + structs as they evolve. Only `http` is rewritten, to default-deny outbound + raw header values. + Eleven of the twelve slots line up 1:1; `http` does not: see "Known divergence" above. Two further corrections to the plan's assumptions: the `delegation` slot exists and is carried, and the plan's `http.headers` is right for Python only. @@ -422,11 +417,11 @@ Landed on `feat/python_plugin_compat_0.1.x`, unreleased: - `EXTENSIONS_FIELD` / `MODIFIED_EXTENSIONS_FIELD` / `SENSITIVE_HEADERS` constants in `cpex/framework/isolated/worker.py`. -- `reconstruct_extensions` — `model_validate` off the task field; drops unknown +- `reconstruct_extensions`: `model_validate` off the task field; drops unknown slots with a warning so a host running ahead of the worker cannot take the channel down, and degrades to `None` on a malformed slot rather than failing the hook. -- `sanitize_extensions_http` — strips the three sensitive names from every header +- `sanitize_extensions_http`: strips the three sensitive names from every header map the model declares, on the way back. Returns the original object untouched when nothing was sensitive. - `process_task` reads the field and passes `extensions=` through @@ -448,7 +443,7 @@ Demonstrated by ### Reproducing the cross-surface run `#[ignore]`d because it needs an installed plugin with a built venv, and the -install alone is **not sufficient** — the worker has to be replaced by hand: +install alone is **not sufficient**: the worker has to be replaced by hand: ``` # 1. Install the plugin. Its venv gets the released cpex, which has no @@ -463,7 +458,7 @@ plugins//.venv/bin/pip install \ cargo test -p cpex-hosts-python --test config_e2e -- --ignored --nocapture ``` -**Watch the version numbers — they collide.** The branch build self-reports +**Watch the version numbers: they collide.** The branch build self-reports `0.1.2`, and PyPI also publishes a `cpex` 0.1.2 that is a *different artifact with no extensions support* (no `EXTENSIONS_FIELD`, no `reconstruct_extensions`). Nothing in `pip list` or the dist-info version distinguishes them, so a venv can @@ -479,7 +474,7 @@ feat/python_plugin_compat_0.1.x`; a registry install has no `direct_url.json` at all. The runs recorded in this document used commit `b6f9f7c`. Step 2 is the reason this is not yet CI-reproducible, and step 3 will regress to a -vacuous pass if a venv is ever rebuilt from the registry — the worker would ignore +vacuous pass if a venv is ever rebuilt from the registry: the worker would ignore the `extensions` field entirely, the hook would still run, and the marker would still land. See Remaining work item 4. @@ -495,8 +490,9 @@ What the test establishes, and what it deliberately does not: - **Establishes** the no-capability contract against that real config, which declares `capabilities: []`: the gated `agent` and `http` slots and the gated `security.labels` sub-field are stripped by `filter_extensions`, while - unrestricted `request` and `custom` cross. And the sensitive-header strip holds — - no credential header value appears anywhere in the serialized task JSON. + unrestricted `request` and `custom` cross. The outbound `http` header maps + are empty; a host may separately place validated request metadata in another + slot such as `request.request_id`. - **Does not** observe what the plugin received from inside the hook body. `cpex-test-plugin`'s hooks are 2-arg `(payload, context)`, and the framework's `_accepts_extensions` forwards extensions only to 3-arg hooks, so this plugin @@ -505,39 +501,36 @@ What the test establishes, and what it deliberately does not: explicit capability sets. What `config_e2e.rs` adds is the real config's no-capability path and proof the channel survives a genuine worker. -The consequence for the `http` divergence is that it does **not** show up as a -failure in either test suite — both sides validate happily and lose the headers in -silence. It needs a test that asserts a header *value* survives the round trip, -which nothing currently does. See Remaining work item 2. +The `http` shape divergence does **not** show up as a validation failure in +either test suite. Outbound raw headers are now withheld intentionally; +worker-returned header edits still need a cross-surface test. See Remaining +work item 2. ## Remaining work -1. **Map the `http` slot** across the two shapes (see "Known divergence"). Until - then the other eleven slots cross correctly and `http` arrives as a - present-but-empty slot on both sides, with no warning. Pick one shape and adapt - the other, or teach each side's serializer to emit both keys during a - transition. +1. **Map the `http` slot** across the two shapes (see "Known divergence") so + permitted worker-returned header edits can survive. Outbound raw headers + remain empty by policy. Pick one shape and adapt the other, or teach each + side's serializer to emit both keys during a transition. -2. **Add a round-trip assertion on a header value.** The gap that let the - divergence be misdescribed for as long as it was: every existing test asserts - either on the wire JSON (Rust-shaped, so it passes) or on hook execution - succeeding (it does), and none asserts that a benign header set on one side is - readable on the other. A test that sends `X-Request-Id` and requires the plugin - to read it back would have failed immediately. Blocked on item 1 — write it as a - failing test first. +2. **Add a cross-surface assertion for an allowed header edit.** Existing + tests can assert an empty outbound header map. A worker-returned header + permitted by `write_headers` needs an end-to-end assertion after item 1. + An outbound value assertion would first require a reviewed, value-validated + exception to the empty allowlist. The same gap hid the capability double-filter (see "The task's `config.capabilities`"). Asserting on a *value the plugin actually read* is what distinguishes a live channel from one that validates cleanly and delivers nothing; `extensions_merge_e2e.rs` now does this for `security.labels`, which - is why that failure surfaced at all. The header assertion is the same shape of - test for the one slot still not covered by it. + is why that failure surfaced at all. The returned-header assertion is the + same shape of test for the remaining mapping gap. 3. **Consider `extra="forbid"` / `deny_unknown_fields` on the extension models.** The permissive default is what converted a loud shape mismatch into a silent one. Strictness here trades the version-skew tolerance `reconstruct_extensions` deliberately provides at the *slot* level, so this is a - real design call and not an obvious win — but the current setting means any + real design call and not an obvious win: but the current setting means any future field rename degrades the same quiet way. 4. **Make the cross-surface run reproducible.** Two parts, both consequences of @@ -550,8 +543,8 @@ which nothing currently does. See Remaining work item 2. path, but `config_e2e.rs` guards only on the venv's *existence*, so a venv carrying the registry `cpex` yields a green test that proves nothing: the worker ignores the unknown `extensions` field, the hook runs, the marker - lands. Grepping the venv's installed `worker.py` for `EXTENSIONS_FIELD` — the - same check the other guard makes — would turn that into an honest skip. + lands. Grepping the venv's installed `worker.py` for `EXTENSIONS_FIELD`: the + same check the other guard makes: would turn that into an honest skip. Until then, treat a green `config_e2e.rs` as conditional on having verified the venv's `direct_url.json`. diff --git a/docs/specs/plugin-framework-spec.md b/docs/specs/plugin-framework-spec.md index 7727c786..bf0571e5 100644 --- a/docs/specs/plugin-framework-spec.md +++ b/docs/specs/plugin-framework-spec.md @@ -3,16 +3,18 @@ **Version**: 2.0 **Related**: [CMF Message Specification](../specs/cmf-message-spec.md) +> **Status.** This is the language-neutral framework design. The shipped engine since CPEX 0.3.0 is the praxis-policy engine (PPE) 0.4.1, and it differs from this design in places: execution modes are `sequential`, `transform`, `audit`, `concurrent`, `fire_and_forget` and `disabled`; the config uses `engine_settings:` with `dispatch: policy` by default; and plugins are selected by APL steps (`run(name)`) rather than by priority alone. The engine object is `PolicyEngine`. For the current behavior, read the [CPEX docs](https://contextforge-org.github.io/cpex/docs/) and the [migration guide](https://contextforge-org.github.io/cpex/docs/migration-0.3/). + ## Introduction This document specifies a hook-based extensibility framework that enables policy enforcement, observability, and extensibility through composable, capability-gated plugins that attach to named hook points in a host system's execution lifecycle. The framework separates four concerns: -* **Payload model** — what plugins operate on, defined as well-typed models. -* **Plugin definition** — what a plugin does, declared via code or configuration. -* **Execution semantics** — how the pipeline dispatches plugins and handles their results. -* **Capability gating** — which payload extensions each plugin can observe and modify. +* **Payload model**: what plugins operate on, defined as well-typed models. +* **Plugin definition**: what a plugin does, declared via code or configuration. +* **Execution semantics**: how the pipeline dispatches plugins and handles their results. +* **Capability gating**: which payload extensions each plugin can observe and modify. The framework operates on any `PluginPayload` subclass. CMF `Message` and `MessageView` are built-in payload types for the common case of message-level policy enforcement, but the same plugin model, scheduling, and capability gating apply to any domain-specific payload. @@ -55,12 +57,12 @@ For the canonical message model, extensions, and `MessageView` abstraction, see ## 1. Design Goals -* **Payload-agnostic** — Operates on any typed, frozen Pydantic payload. CMF messages are a built-in payload type, not the only one. Domain-specific hooks define domain-specific payloads. -* **Composable** — Multiple plugins attach to the same hook point and execute in priority order. Plugins are grouped into reusable sets. -* **Minimal intrusion** — Plugins are opt-in. Without registered plugins, hook invocations are no-ops with zero overhead. -* **Fail-safe** — Plugin failures are isolated. Error handling behavior is configurable per-plugin, independent of mode. -* **Least privilege** — Plugins declare capabilities. The framework filters payload extensions per-plugin so each sees only the data it needs. -* **Safe mutation** — Plugins that modify payloads operate on copies. The original payload is preserved for audit, rollback, and parallel inspection. +* **Payload-agnostic**: Operates on any typed, frozen Pydantic payload. CMF messages are a built-in payload type, not the only one. Domain-specific hooks define domain-specific payloads. +* **Composable**: Multiple plugins attach to the same hook point and execute in priority order. Plugins are grouped into reusable sets. +* **Minimal intrusion**: Plugins are opt-in. Without registered plugins, hook invocations are no-ops with zero overhead. +* **Fail-safe**: Plugin failures are isolated. Error handling behavior is configurable per-plugin, independent of mode. +* **Least privilege**: Plugins declare capabilities. The framework filters payload extensions per-plugin so each sees only the data it needs. +* **Safe mutation**: Plugins that modify payloads operate on copies. The original payload is preserved for audit, rollback, and parallel inspection. ## 2. Framework Model @@ -79,7 +81,7 @@ This section introduces the type system that the rest of the specification build | `Plugin` | Abstract base class with `initialize()`, `shutdown()`, and `@hook`-decorated methods. | | `PluginConfig` | Plugin declaration: `name`, `kind`, `mode`, `on_error`, `priority`, `capabilities`, `config`. | | `PluginContext` | Per-invocation execution state: `local_state`, `global_state`. | -| `PluginManager` | Singleton managing plugin lifecycle and dispatch. | +| `PolicyEngine` | Singleton managing plugin lifecycle and dispatch. | | `PluginSet` | Composable, reusable grouping of related hooks and plugins. | | `@hook` | Decorator for registering async functions or methods as hook handlers. | | `@plugin` | Convenience decorator for marking a plain class as a multi-hook plugin. | @@ -100,9 +102,9 @@ class PluginPayload(BaseModel, frozen=True): **Design rationale:** -* **Frozen** — Payloads are immutable by default. Mutation is only possible through copy-on-write in `modify` mode. -* **Optional extensions** — Payloads that carry extensions get automatic capability filtering. Payloads without extensions skip filtering entirely. -* **Pydantic** — Provides validation, serialization, and `model_copy()` for COW semantics. +* **Frozen**: Payloads are immutable by default. Mutation is only possible through copy-on-write in `modify` mode. +* **Optional extensions**: Payloads that carry extensions get automatic capability filtering. Payloads without extensions skip filtering entirely. +* **Pydantic**: Provides validation, serialization, and `model_copy()` for COW semantics. Domain-specific payloads subclass `PluginPayload` and add their own fields: @@ -120,15 +122,15 @@ class ToolInvocationPayload(PluginPayload): server_id: str | None = None ``` -These payloads participate in the same plugin pipeline — same modes, same scheduling, same result types. When they carry extensions, capability gating works identically to CMF message payloads. +These payloads participate in the same plugin pipeline: same modes, same scheduling, same result types. When they carry extensions, capability gating works identically to CMF message payloads. ### 2.3 CMF Integration -CMF `Message` and `MessageView` are the primary built-in payload types. They are used by the standard hook points that process message-level interactions — the common case for policy enforcement, content scanning, and observability in agentic systems. +CMF `Message` and `MessageView` are the primary built-in payload types. They are used by the standard hook points that process message-level interactions: the common case for policy enforcement, content scanning, and observability in agentic systems. ```python class MessagePayload(PluginPayload): - """CMF message payload — the primary built-in type. + """CMF message payload: the primary built-in type. Wraps a CMF Message and its decomposed views for policy evaluation. Extensions are always present (inherited from the message). @@ -152,13 +154,13 @@ class MessagePayload(PluginPayload): Plugins can be written against `PluginPayload` for maximum generality, or against `MessagePayload` (or any other subclass) when they need domain-specific fields: ```python -# Generic — works with any payload that has extensions +# Generic: works with any payload that has extensions @hook("any_hook", mode="validate") async def check_labels(payload: PluginPayload, ctx): if payload.extensions and "RESTRICTED" in payload.extensions.security.labels: return block("Restricted content") -# CMF-specific — accesses message views +# CMF-specific: accesses message views @hook("message_pre_send", mode="validate") async def check_tool_calls(payload: MessagePayload, ctx): for view in payload.views: @@ -230,9 +232,9 @@ async def log_tool_call(payload, ctx): **Parameters:** -- `hook_type: str` — the hook point name (required). -- `mode: str` — `"observe"`, `"validate"` (default), or `"modify"`. -- `priority: int` — lower numbers execute first (default: `50`). +- `hook_type: str`: the hook point name (required). +- `mode: str`: `"observe"`, `"validate"` (default), or `"modify"`. +- `priority: int`: lower numbers execute first (default: `50`). The `block()` helper is shorthand for returning a deny result: @@ -250,12 +252,12 @@ def block(reason: str, *, description: str = None, code: str = "", details: dict For plugins that need shared state across multiple hooks, use the `@plugin` decorator or subclass the `Plugin` base class. -**`@plugin` decorator** — marks a plain class as a multi-hook plugin. Parameters: +**`@plugin` decorator**: marks a plain class as a multi-hook plugin. Parameters: -- `name: str` — unique plugin identifier (required). -- `priority: int` — default priority for all hooks in this plugin (default: `50`). Per-handler `@hook` priorities override this. -- `on_error: str` — default error handling for all hooks (default: `"fail"`). -- `capabilities: list[str]` — extension capabilities for all hooks (default: `[]`). +- `name: str`: unique plugin identifier (required). +- `priority: int`: default priority for all hooks in this plugin (default: `50`). Per-handler `@hook` priorities override this. +- `on_error: str`: default error handling for all hooks (default: `"fail"`). +- `capabilities: list[str]`: extension capabilities for all hooks (default: `[]`). ```python from framework import plugin, hook, PluginResult @@ -282,7 +284,7 @@ class PIIRedactor: return text ``` -**`Plugin` base class** — for plugins that need lifecycle hooks (`initialize`/`shutdown`): +**`Plugin` base class**: for plugins that need lifecycle hooks (`initialize`/`shutdown`): ```python from framework import Plugin, hook @@ -326,7 +328,7 @@ observability = PluginSet("observability", [ ### 3.4 Registration & Scoping -**Global** — fires for all hook invocations: +**Global**: fires for all hook invocations: ```python from framework import register @@ -336,14 +338,14 @@ register([security, observability]) register(enforce_budget) ``` -**Context manager** — fires within a `with` block: +**Context manager**: fires within a `with` block: Plugins can be activated for a specific block of code using context managers: ```python from framework import plugin_scope -# Factory — accepts any mix of hooks, plugins, and sets +# Factory: accepts any mix of hooks, plugins, and sets with plugin_scope(enforce_budget, PIIRedactor()): result = generate("Name the planets.") # plugins deregistered here @@ -358,7 +360,7 @@ with observability: result = generate("What is the capital of France?") ``` -All forms support `async with`. Scopes stack cleanly — each exit deregisters only its own plugins. The same instance cannot be active in two overlapping scopes simultaneously; create separate instances for concurrent use. +All forms support `async with`. Scopes stack cleanly: each exit deregisters only its own plugins. The same instance cannot be active in two overlapping scopes simultaneously; create separate instances for concurrent use. ## 4. Plugin Behavior @@ -414,9 +416,9 @@ Each mode grants specific capabilities: Execution scheduling is determined automatically from mode: -* **Observe plugins** — Executed asynchronously. Never block the pipeline. May run in parallel. -* **Validate plugins** — Executed in parallel. Pipeline waits for completion. Safe parallelism due to read-only access. -* **Modify plugins** — Executed sequentially. Each plugin receives the output of the previous plugin. Ensures deterministic transformation order. +* **Observe plugins**: Executed asynchronously. Never block the pipeline. May run in parallel. +* **Validate plugins**: Executed in parallel. Pipeline waits for completion. Safe parallelism due to read-only access. +* **Modify plugins**: Executed sequentially. Each plugin receives the output of the previous plugin. Ensures deterministic transformation order. Plugin authors do not control scheduling directly. Scheduling is derived from mode to guarantee safety and correctness. @@ -505,7 +507,7 @@ Only `modify` plugins receive mutable payloads. All modifications use copy-on-wr * Modified copies propagate forward * Original remains available for audit and rollback -For payloads that carry extensions, COW copies respect extension mutability tiers: immutable extensions are shared by reference, monotonic extensions are validated add-only, guarded extensions require write capabilities. See [CMF Message Specification — Mutability Tiers](../specs/cmf-message-spec.md#31-mutability-tiers). Payloads without extensions skip tier enforcement. +For payloads that carry extensions, COW copies respect extension mutability tiers: immutable extensions are shared by reference, monotonic extensions are validated add-only, guarded extensions require write capabilities. See [CMF Message Specification: Mutability Tiers](../specs/cmf-message-spec.md#31-mutability-tiers). Payloads without extensions skip tier enforcement. This ensures: @@ -548,7 +550,7 @@ async def redact_pii(payload: MessagePayload, ctx): ### 5.1 Hook Invocation -Hook point names (e.g., `generation_pre_call`, `tool_post_invoke`, `message_pre_send`) are defined by the host system that integrates the plugin framework. This specification does not prescribe a fixed set of hook points — each host defines the hook points appropriate to its execution lifecycle and places `invoke_hook()` calls at those sites. Hook names used in examples throughout this specification are illustrative. +Hook point names (e.g., `generation_pre_call`, `tool_post_invoke`, `message_pre_send`) are defined by the host system that integrates the plugin framework. This specification does not prescribe a fixed set of hook points: each host defines the hook points appropriate to its execution lifecycle and places `invoke_hook()` calls at those sites. Hook names used in examples throughout this specification are illustrative. The pipeline dispatches plugins through a single entry point placed at hook invocation sites: @@ -566,10 +568,10 @@ async def invoke_hook( `PipelineResult` is a convenience wrapper around `PluginResult` returned by the pipeline after all hooks for an invocation have run. It aggregates individual plugin results and provides factory methods for the common outcomes: -* `PipelineResult.allowed(payload)` — all plugins allowed; returns the (possibly modified) payload. -* `PipelineResult.denied(results)` — at least one plugin denied; carries the violation(s). +* `PipelineResult.allowed(payload)`: all plugins allowed; returns the (possibly modified) payload. +* `PipelineResult.denied(results)`: at least one plugin denied; carries the violation(s). -Callers treat `PipelineResult` identically to `PluginResult` — it exposes the same `continue_processing`, `modified_payload`, and `violation` fields. +Callers treat `PipelineResult` identically to `PluginResult`: it exposes the same `continue_processing`, `modified_payload`, and `violation` fields. The caller (typically a framework base class) invokes the hook and processes one of three outcomes: @@ -701,7 +703,7 @@ When no policy is registered for a hook, the `DefaultHookPolicy` determines beha | `ALLOW` | All payload fields are writable (default) | | `DENY` | No payload fields are writable; modify plugins are rejected at registration | -Hook payload policies are registered on the `PluginManager` and are independent of plugin capabilities — capabilities control *extension* visibility, while hook policies control *field-level* write scope. +Hook payload policies are registered on the `PolicyEngine` and are independent of plugin capabilities: capabilities control *extension* visibility, while hook policies control *field-level* write scope. #### Capability filtering and isolation @@ -764,9 +766,9 @@ This ensures negligible overhead when the framework is unused. ## 6. Capability-Gated Extensions -Plugins declare which extensions they need. The pipeline builds payload views with only the granted extensions visible — ungated extensions appear as `None`. Capability gating applies to any `PluginPayload` that carries extensions, not only CMF messages. +Plugins declare which extensions they need. The pipeline builds payload views with only the granted extensions visible: ungated extensions appear as `None`. Capability gating applies to any `PluginPayload` that carries extensions, not only CMF messages. -For the full extension model and mutability tiers, see [CMF Message Specification — Extensions](../specs/cmf-message-spec.md#3-extensions). +For the full extension model and mutability tiers, see [CMF Message Specification: Extensions](../specs/cmf-message-spec.md#3-extensions). ### 6.1 Capability Model @@ -798,11 +800,11 @@ For the full extension model and mutability tiers, see [CMF Message Specificatio A plugin that only needs content for scanning gets no subject, no headers, no labels. A plugin doing role-based authorization declares `read_subject` + `read_roles` and gets exactly that. -The base tier and informational extensions shown above apply to CMF `MessageView` accessors. Informational extensions (`completion`, `provenance`, `mcp`, `llm`, `framework`) are always visible because they carry no security-sensitive data — they describe the message's origin and processing context. The `custom` extension requires `write_custom` to modify but is always readable. For non-CMF payloads, the base tier includes the payload's own fields; capability gating controls only the extensions. +The base tier and informational extensions shown above apply to CMF `MessageView` accessors. Informational extensions (`completion`, `provenance`, `mcp`, `llm`, `framework`) are always visible because they carry no security-sensitive data: they describe the message's origin and processing context. The `custom` extension requires `write_custom` to modify but is always readable. For non-CMF payloads, the base tier includes the payload's own fields; capability gating controls only the extensions. ### 6.2 Extension Filtering -The pipeline filters extensions per-plugin before dispatch. No intermediate context object is needed — the extensions themselves are the gating surface. This filter applies to any `PluginPayload` whose `extensions` field is not `None`. +The pipeline filters extensions per-plugin before dispatch. No intermediate context object is needed: the extensions themselves are the gating surface. This filter applies to any `PluginPayload` whose `extensions` field is not `None`. ```python class ExtensionFilter: @@ -813,7 +815,7 @@ class ExtensionFilter: """Return a filtered copy with only granted extensions visible.""" filtered = Extensions() - # Base tier — always visible + # Base tier: always visible filtered.request = extensions.request # Capability-gated: subject and security fields @@ -847,20 +849,20 @@ class ExtensionFilter: if "read_agent" in self.caps: filtered.agent = extensions.agent - # Immutable informational extensions — always visible + # Immutable informational extensions: always visible filtered.completion = extensions.completion filtered.provenance = extensions.provenance filtered.mcp = extensions.mcp filtered.llm = extensions.llm filtered.framework = extensions.framework - # Custom — always readable, writable with write_custom + # Custom: always readable, writable with write_custom filtered.custom = extensions.custom return filtered ``` -Ungated extensions are `None` on the view. Existing `MessageView` accessors handle this naturally — `view.roles` returns an empty set if the subject extension was not granted, `view.has_role("admin")` returns `False`. +Ungated extensions are `None` on the view. Existing `MessageView` accessors handle this naturally: `view.roles` returns an empty set if the subject extension was not granted, `view.has_role("admin")` returns `False`. ### 6.3 Capability in Practice @@ -961,19 +963,19 @@ class PluginConfig: config: dict[str, Any] = {} # Plugin-specific settings ``` -**`kind`** — For native plugins, this is the class path (e.g., `plugins.security.rate_limiter.RateLimiter`). The framework dynamically imports and instantiates the class. For external plugins, use `"external"` — connection details are specified separately. +**`kind`**: For native plugins, this is the class path (e.g., `plugins.security.rate_limiter.RateLimiter`). The framework dynamically imports and instantiates the class. For external plugins, use `"external"`: connection details are specified separately. -**`mode`** — The plugin's authority level: `observe`, `validate`, or `modify` (see [4.1](#41-plugin-modes)). +**`mode`**: The plugin's authority level: `observe`, `validate`, or `modify` (see [4.1](#41-plugin-modes)). -**`on_error`** — Error handling behavior: `fail`, `ignore`, or `disable` (see [4.2](#42-error-handling)). +**`on_error`**: Error handling behavior: `fail`, `ignore`, or `disable` (see [4.2](#42-error-handling)). -**`capabilities`** — Extension capabilities the plugin requires (see [6.1](#61-capability-model)). The pipeline filters extensions before dispatching. Ungated extensions are `None`. +**`capabilities`**: Extension capabilities the plugin requires (see [6.1](#61-capability-model)). The pipeline filters extensions before dispatching. Ungated extensions are `None`. -**`priority`** — Controls execution order. Lower numbers run first. Plugins at the same priority may be parallelized when their modes permit. +**`priority`**: Controls execution order. Lower numbers run first. Plugins at the same priority may be parallelized when their modes permit. ### 7.2 External Plugins -External plugins run as separate processes — MCP servers, HTTP services, or WASM modules. The framework serializes the capability-filtered payload and sends it to the external plugin. The plugin returns a `PluginResult` as JSON. External plugins never see extensions they have not been granted. +External plugins run as separate processes: MCP servers, HTTP services, or WASM modules. The framework serializes the capability-filtered payload and sends it to the external plugin. The plugin returns a `PluginResult` as JSON. External plugins never see extensions they have not been granted. ```yaml plugins: @@ -1073,7 +1075,7 @@ YAML configuration is secondary to programmatic registration. When both are pres ### 7.4 Global Settings ```yaml -plugin_settings: +engine_settings: # 0.3.0 name; this block shows the design's settings plugin_timeout: 30 # Default timeout (seconds) on_error: fail # Global default error behavior observe_pool_size: 4 # Thread pool for async observe plugins @@ -1094,11 +1096,11 @@ PluginContext └── global_state: dict # Shared across plugins. Use with care. ``` -All data needed for policy evaluation comes from the payload's extensions (filtered by capabilities). `PluginContext` is purely for transient execution state — counters, caches, intermediate results. +All data needed for policy evaluation comes from the payload's extensions (filtered by capabilities). `PluginContext` is purely for transient execution state: counters, caches, intermediate results. ### 8.2 Manager Lifecycle & Execution Flow -The `PluginManager` owns the plugin lifecycle and execution pipeline. A host system typically maintains a single `PluginManager` instance for its lifetime. The manager must be initialized before dispatching hooks and shut down gracefully to flush observe queues and disconnect external transports. +The `PolicyEngine` owns the plugin lifecycle and execution pipeline. A host system typically maintains a single `PolicyEngine` instance for its lifetime. The manager must be initialized before dispatching hooks and shut down gracefully to flush observe queues and disconnect external transports. **Lifecycle:** @@ -1122,7 +1124,7 @@ stateDiagram-v2 **Execution flow:** ```python -class PluginManager: +class PolicyEngine: async def invoke_hook( self, hook_type: str, payload: PluginPayload, context: PluginContext ) -> PluginResult: @@ -1191,7 +1193,7 @@ classDiagram +hook_name(payload, context)* async PluginResult } - class PluginManager { + class PolicyEngine { -timeout: int -hook_policies: dict~str, HookPayloadPolicy~ +invoke_hook(hook_type, payload, context) PipelineResult @@ -1280,9 +1282,9 @@ classDiagram Plugin ..> PluginContext : receives Plugin ..> hook : decorated by - PluginManager --> Plugin : manages 0..* - PluginManager --> HookPayloadPolicy : enforces per hook - PluginManager --> PluginSet : loads from + PolicyEngine --> Plugin : manages 0..* + PolicyEngine --> HookPayloadPolicy : enforces per hook + PolicyEngine --> PluginSet : loads from PluginConfig --> PluginMode : has PluginConfig --> OnError : has @@ -1290,7 +1292,7 @@ classDiagram PluginResult --> PluginViolation : may contain %%PluginResult --> PluginPayload : wraps modified PipelineResult --|> PluginResult : extends - PluginManager ..> PipelineResult : returns + PolicyEngine ..> PipelineResult : returns ``` ## 9. Properties diff --git a/examples/go-demo/README.md b/examples/go-demo/README.md index cfc4016b..2774e627 100644 --- a/examples/go-demo/README.md +++ b/examples/go-demo/README.md @@ -1,6 +1,6 @@ # CPEX Go Demo -Two runnable examples showing the full CPEX plugin pipeline from Go, with plugins written in Rust and loaded via YAML configuration. +Two runnable examples showing the full CPEX plugin pipeline from Go, with plugins written in Rust and loaded via YAML configuration. The plugins are host demo plugins registered by the demo FFI crate, not CPEX builtins. Both configs use hook dispatch (`engine_settings.dispatch: hooks`): each plugin fires at the hooks it declares, ordered by `priority`. ## Prerequisites @@ -22,13 +22,13 @@ go build -o cmf-demo ./cmd/cmf-demo/ ## Demo 1: Generic Payload (`cpex-demo`) -Uses `PayloadGeneric` (untyped `map[string]any`) with three plugins: +Uses `PayloadGeneric` (untyped `map[string]any`) with three plugins on the demo's own hooks, `demo.tool_pre_invoke` and `demo.tool_post_invoke`. The `cmf.*` hooks carry a typed `MessagePayload`, so a generic-map host declares its own hooks; the demo FFI crate registers their metadata before the config loads. | Plugin | Kind | Mode | What it does | |--------|------|------|-------------| -| identity-checker | `builtin/identity` | sequential | Validates `user` field present | -| pii-guard | `builtin/pii` | sequential | Blocks PII-tagged tools without clearance | -| audit-logger | `builtin/audit` | fire_and_forget | Logs tool invocations | +| identity-checker | `demo/identity` | sequential | Validates `user` field present | +| pii-guard | `demo/pii` | sequential | Blocks PII-tagged tools without clearance | +| audit-logger | `demo/audit` | fire_and_forget | Logs tool invocations | ### Run @@ -43,7 +43,7 @@ cd examples/go-demo === CPEX Go Demo === Plugins loaded: 3 -Hooks: tool_pre_invoke=true tool_post_invoke=true +Hooks: demo.tool_pre_invoke=true demo.tool_post_invoke=true === Scenario 1: get_compensation (no PII clearance) === Result: DENIED — PII clearance required for this operation [pii_access_denied] @@ -60,7 +60,7 @@ Hooks: tool_pre_invoke=true tool_post_invoke=true ### Config -See [`plugins.yaml`](plugins.yaml) for the full configuration including routing rules and policy groups. +See [`plugins.yaml`](plugins.yaml) for the full configuration. ## Demo 2: CMF Payload (`cmf-demo`) @@ -68,8 +68,8 @@ Uses `PayloadCMFMessage` (typed CMF messages) with rich extensions and two plugi | Plugin | Kind | Mode | What it does | |--------|------|------|-------------| -| tool-policy | `builtin/cmf-tool-policy` | sequential | Checks tool permissions against security labels | -| header-injector | `builtin/cmf-header-injector` | sequential | Injects response headers via capability-gated write | +| tool-policy | `demo/cmf-tool-policy` | sequential | Checks tool permissions against security labels | +| header-injector | `demo/cmf-header-injector` | sequential | Injects response headers via capability-gated write | ### Run @@ -86,7 +86,7 @@ cd examples/go-demo Plugins loaded: 2 === Scenario 1: get_compensation tool call (no PII label) === - Result: DENIED — Tool 'get_compensation' is PII-tagged but security context lacks PII label + Result: DENIED: Tool 'get_compensation' is PII-tagged but security context lacks PII label [pii_label_required] === Scenario 2: get_compensation tool call (with PII label) === Result: ALLOWED @@ -104,18 +104,18 @@ Plugins loaded: 2 ### Config -See [`cmf_plugins.yaml`](cmf_plugins.yaml) for capabilities and routing. +See [`cmf_plugins.yaml`](cmf_plugins.yaml) for capabilities. ## Architecture ``` Go (main.go) │ - │ cpex.NewPluginManagerDefault() - │ cpex.RegisterFactories(callback) ← one raw C call - │ cpex.LoadConfig(yaml) - │ cpex.Initialize() - │ cpex.InvokeByName(hook, payload, extensions, ...) + │ cpex.NewPolicyEngineDefault() + │ engine.RegisterFactories(callback) ← one raw C call + │ engine.LoadConfig(yaml) + │ engine.Initialize() + │ engine.InvokeByName(hook, payload, extensions, ...) │ ▼ Go SDK (go/cpex/) @@ -156,7 +156,7 @@ examples/go-demo/ Cargo.toml — Rust crate: cpex-demo-ffi src/ lib.rs — C FFI: cpex_demo_register_factories() - demo_plugins.rs — 3 generic plugins (identity, PII, audit) + demo_plugins.rs - demo hooks + 3 generic plugins (identity, PII, audit) cmf_plugins.rs — 2 CMF plugins (tool-policy, header-injector) ``` @@ -164,11 +164,11 @@ The `cpex-demo-ffi` crate builds a staticlib that includes both the core `cpex-f ## How Factory Registration Works -The Go SDK's `PluginManager` wraps the Rust manager. Plugin factories are Rust code, so registration happens through a callback: +The Go SDK's `PolicyEngine` wraps the Rust engine. Plugin factories are Rust code, so registration happens through a callback: ```go -mgr.RegisterFactories(func(handle unsafe.Pointer) error { - // handle is the raw Rust manager pointer +engine.RegisterFactories(func(handle unsafe.Pointer) error { + // handle is the raw cpex_ffi::CpexEngineInner pointer // Call your crate's C registration function C.cpex_demo_register_factories(handle) return nil @@ -177,6 +177,8 @@ mgr.RegisterFactories(func(handle unsafe.Pointer) error { This keeps the Go SDK generic — it doesn't know about specific factories. Each Rust crate exports its own `register_*_factories()` function. +To use the CPEX builtins (`identity/jwt`, `identity/api-key`, `delegator/oauth`, `elicitation/ciba`, the PDPs and Vault) as well, call `engine.EnableAPL()` before `LoadConfig`. Pass `cpex.WithPrivateDestinations()` when a builtin must reach a localhost or in-cluster address; those are blocked by default. + --- # Adding New Payload Types and Hooks @@ -191,12 +193,13 @@ The CPEX payload type registry maps a `uint8` discriminator to a concrete Rust t |----|----------|-----------|---------| | 0 | `PAYLOAD_GENERIC` | `GenericPayload` | `map[string]any` | | 1 | `PAYLOAD_CMF_MESSAGE` | `MessagePayload` | `MessagePayload` | +| 2 | `PAYLOAD_IDENTITY` | `IdentityPayload` | `IdentityPayload` | ## Step-by-Step: Adding a New Payload Type ### 1. Define the Rust payload type -In your Rust crate (e.g., `cpex-core` or a separate crate): +In your Rust crate: ```rust #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -204,7 +207,7 @@ pub struct MyPayload { pub field_a: String, pub field_b: i64, } -cpex_core::impl_plugin_payload!(MyPayload); +praxis_policy_core::impl_plugin_payload!(MyPayload); ``` ### 2. Register the payload type in the FFI crate @@ -213,7 +216,7 @@ In `crates/cpex-ffi/src/lib.rs`: ```rust // Add the constant -pub const PAYLOAD_MY_TYPE: u8 = 2; +pub const PAYLOAD_MY_TYPE: u8 = 3; // Add a deserialize arm fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result<...> { @@ -242,7 +245,7 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Option<(u8, Vec)> { In `go/cpex/types.go` (or a new file): ```go -const PayloadMyType uint8 = 2 +const PayloadMyType uint8 = 3 type MyPayload struct { FieldA string `msgpack:"field_a"` @@ -253,8 +256,8 @@ type MyPayload struct { ### 4. Use it ```go -result, ct, bg, err := mgr.InvokeByName( - "my_hook", +result, ct, bg, err := engine.InvokeByName( + "my.hook", cpex.PayloadMyType, MyPayload{FieldA: "hello", FieldB: 42}, ext, @@ -275,7 +278,7 @@ modified, err := cpex.DeserializePayload[MyPayload](result) ## Step-by-Step: Adding a New Hook Type -Hooks define what payload goes in and what comes out. For Go callers, hooks are identified by string name (e.g., `"tool_pre_invoke"`). +Hooks define what payload goes in and what comes out. For Go callers, hooks are identified by string name (e.g., `"cmf.tool_pre_invoke"`). A host hook needs its metadata registered before any config naming it loads; the config loader refuses a hook name it does not know. ### 1. Define the hook type in Rust @@ -286,6 +289,13 @@ impl HookTypeDef for MyHook { type Result = PluginResult; const NAME: &'static str = "my_hook"; } + +// Describe the hook to the engine. `family` must match the hook type's +// NAME, so only handlers for MyHook can register on it. +register_hook_metadata( + "my.hook", + HookMetadata { family: Some(MyHook::NAME), entity_type: None, phase: HookPhase::Unphased }, +); ``` ### 2. Write a plugin that handles it @@ -309,12 +319,12 @@ impl HookHandler for MyPlugin { ```rust struct MyPluginFactory; impl PluginFactory for MyPluginFactory { - fn create(&self, config: &PluginConfig) -> Result { + fn create(&self, config: &PluginConfig) -> Result> { let plugin = Arc::new(MyPlugin { cfg: config.clone() }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("my_hook", Arc::new(TypedHandlerAdapter::::new(plugin))), + ("my.hook", Arc::new(TypedHandlerAdapter::::new(plugin))), ], }) } @@ -324,26 +334,31 @@ impl PluginFactory for MyPluginFactory { ### 4. Register in your FFI crate ```rust -pub fn register_my_factories(manager: &mut PluginManager) { - manager.register_factory("my-plugin-kind", Box::new(MyPluginFactory)); +pub fn register_my_factories(engine: &PolicyEngine) { + engine.register_factory("my-plugin-kind", Box::new(MyPluginFactory)); } ``` ### 5. Add to YAML config ```yaml +engine_settings: + dispatch: hooks + plugins: - name: my-plugin kind: my-plugin-kind - hooks: [my_hook] + hooks: [my.hook] mode: sequential priority: 10 ``` +`priority` is accepted only under `dispatch: hooks`. Under the default `dispatch: policy`, call `EnableAPL` and name the plugin from a policy step instead, for example `run(my-plugin)` under a route's `authorization.pre_invocation`. + ### 6. Invoke from Go ```go -result, ct, bg, err := mgr.InvokeByName("my_hook", cpex.PayloadMyType, payload, ext, nil) +result, ct, bg, err := engine.InvokeByName("my.hook", cpex.PayloadMyType, payload, ext, nil) ``` The Go side doesn't need to know about the Rust hook type — it just uses the string name and the payload type constant. diff --git a/examples/go-demo/cmd/cmf-demo/main.go b/examples/go-demo/cmd/cmf-demo/main.go index b577b4da..1277e6ef 100644 --- a/examples/go-demo/cmd/cmf-demo/main.go +++ b/examples/go-demo/cmd/cmf-demo/main.go @@ -32,7 +32,7 @@ package main #cgo darwin LDFLAGS: -framework CoreFoundation -framework Security #include -int cpex_demo_register_factories(void* mgr); +int cpex_demo_register_factories(void* engine); */ import "C" @@ -49,13 +49,13 @@ func main() { fmt.Println() // --- Setup --- - mgr, err := cpex.NewPluginManagerDefault() + engine, err := cpex.NewPolicyEngineDefault() if err != nil { - fatal("create manager: %v", err) + fatal("create engine: %v", err) } - defer mgr.Shutdown() + defer engine.Shutdown() - err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + err = engine.RegisterFactories(func(handle unsafe.Pointer) error { if C.cpex_demo_register_factories(handle) != 0 { return fmt.Errorf("factory registration failed") } @@ -74,17 +74,17 @@ func main() { } } - if err := mgr.LoadConfig(string(yaml)); err != nil { + if err := engine.LoadConfig(string(yaml)); err != nil { fatal("load config: %v", err) } - if err := mgr.Initialize(); err != nil { + if err := engine.Initialize(); err != nil { fatal("initialize: %v", err) } - fmt.Printf("Plugins loaded: %d\n", mgr.PluginCount()) + fmt.Printf("Plugins loaded: %d\n", engine.PluginCount()) fmt.Printf("Hooks: cmf.tool_pre_invoke=%v cmf.tool_post_invoke=%v\n\n", - mgr.HasHooksFor("cmf.tool_pre_invoke"), - mgr.HasHooksFor("cmf.tool_post_invoke"), + engine.HasHooksFor("cmf.tool_pre_invoke"), + engine.HasHooksFor("cmf.tool_post_invoke"), ) // ----------------------------------------------------------------------- @@ -130,7 +130,7 @@ func main() { }, } - result, ct, bg, err := mgr.InvokeByName("cmf.tool_pre_invoke", + result, ct, bg, err := engine.InvokeByName("cmf.tool_pre_invoke", cpex.PayloadCMFMessage, msg, ext, nil) if err != nil { fatal("invoke: %v", err) @@ -147,7 +147,7 @@ func main() { ext.Security.Labels = []string{"PII", "HR"} // now has PII label - result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + result, ct, bg, err = engine.InvokeByName("cmf.tool_pre_invoke", cpex.PayloadCMFMessage, msg, ext, nil) if err != nil { fatal("invoke: %v", err) @@ -208,7 +208,7 @@ func main() { }, } - result2, ct2, bg2, err := mgr.InvokeByName("cmf.tool_post_invoke", + result2, ct2, bg2, err := engine.InvokeByName("cmf.tool_post_invoke", cpex.PayloadCMFMessage, resultMsg, postExt, ct) if err != nil { fatal("post-invoke: %v", err) @@ -249,7 +249,7 @@ func main() { }, } - result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + result, ct, bg, err = engine.InvokeByName("cmf.tool_pre_invoke", cpex.PayloadCMFMessage, textMsg, textExt, nil) if err != nil { fatal("invoke: %v", err) diff --git a/examples/go-demo/cmf_plugins.yaml b/examples/go-demo/cmf_plugins.yaml index 53664a36..cb600226 100644 --- a/examples/go-demo/cmf_plugins.yaml +++ b/examples/go-demo/cmf_plugins.yaml @@ -5,27 +5,23 @@ # # CMF Demo — plugin configuration # -# Two CMF plugins demonstrating typed message processing: +# Two host demo plugins demonstrating typed message processing: # - Tool policy: checks tool permissions against security labels # - Header injector: adds response headers after processing # # Uses cmf.tool_pre_invoke and cmf.tool_post_invoke hooks with # MessagePayload (typed CMF messages, not generic maps). +# +# Hook dispatch: each plugin fires at the hooks it declares, ordered by +# priority within its mode, for every tool. -plugin_settings: - routing_enabled: true +engine_settings: + dispatch: hooks plugin_timeout: 30 -global: - policies: - all: - plugins: [tool-policy] - pii: - plugins: [tool-policy] - plugins: - name: tool-policy - kind: builtin/cmf-tool-policy + kind: demo/cmf-tool-policy hooks: [cmf.tool_pre_invoke] mode: sequential priority: 10 @@ -35,7 +31,7 @@ plugins: - read_subject - name: header-injector - kind: builtin/cmf-header-injector + kind: demo/cmf-header-injector hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] mode: sequential priority: 50 @@ -43,18 +39,3 @@ plugins: capabilities: - read_headers - write_headers - -routes: - - tool: get_compensation - meta: - tags: [pii, hr] - plugins: - - header-injector - - - tool: list_departments - plugins: - - header-injector - - - tool: "*" - plugins: - - header-injector diff --git a/examples/go-demo/ffi/Cargo.toml b/examples/go-demo/ffi/Cargo.toml index 8cfbe206..a0aae397 100644 --- a/examples/go-demo/ffi/Cargo.toml +++ b/examples/go-demo/ffi/Cargo.toml @@ -12,7 +12,7 @@ [package] name = "cpex-demo-ffi" version = "0.1.0" -edition = "2021" +edition.workspace = true license = "Apache-2.0" publish = false @@ -26,7 +26,7 @@ release = false crate-type = ["staticlib", "cdylib"] [dependencies] -cpex-core = { path = "../../../crates/cpex-core" } +praxis-policy-core = { workspace = true } cpex-ffi = { path = "../../../crates/cpex-ffi" } async-trait = "0.1" tracing = "0.1" diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index dd85aa49..c6df1de1 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -22,29 +22,15 @@ use std::sync::Arc; use async_trait::async_trait; -use cpex_core::cmf::{ContentPart, MessagePayload}; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; - -// --------------------------------------------------------------------------- -// CMF Hook Type -// --------------------------------------------------------------------------- - -/// Hook type for CMF message processing. The hook *name* varies -/// (cmf.tool_pre_invoke, cmf.tool_post_invoke, etc.) but the payload -/// is always MessagePayload. -pub struct CmfHook; - -impl HookTypeDef for CmfHook { - type Payload = MessagePayload; - type Result = PluginResult; - const NAME: &'static str = "cmf"; -} +use praxis_policy_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::{PluginError, PluginViolation}; +use praxis_policy_core::factory::{PluginFactory, PluginInstance}; +use praxis_policy_core::hooks::adapter::TypedHandlerAdapter; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::hooks::trait_def::{HookHandler, PluginResult}; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; // --------------------------------------------------------------------------- // Tool Policy Plugin @@ -222,16 +208,13 @@ impl HookHandler for HeaderInjectorPlugin { let mut owned = extensions.cow_copy(); // Write to HTTP extension — requires write token from capability - if let Some(ref token) = owned.http_write_token { - if let Some(http) = owned.http.as_mut() { - let h = http.write(token); - h.set_response_header("X-Tool-Name", name); - h.set_response_header( - "X-Tool-Status", - if is_error { "error" } else { "success" }, - ); - h.set_response_header("X-CPEX-Processed", "true"); - } + if let Some(ref token) = owned.http_write_token + && let Some(http) = owned.http.as_mut() + { + let h = http.write(token); + h.set_response_header("X-Tool-Name", name); + h.set_response_header("X-Tool-Status", if is_error { "error" } else { "success" }); + h.set_response_header("X-CPEX-Processed", "true"); } return PluginResult::modify_extensions(owned); @@ -264,11 +247,8 @@ impl PluginFactory for HeaderInjectorFactory { } } -/// Register CMF demo plugin factories on a manager. -pub fn register_cmf_factories(manager: &cpex_core::manager::PluginManager) { - manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); - manager.register_factory( - "builtin/cmf-header-injector", - Box::new(HeaderInjectorFactory), - ); +/// Register CMF demo plugin factories on an engine. +pub fn register_cmf_factories(engine: &PolicyEngine) { + engine.register_factory("demo/cmf-tool-policy", Box::new(ToolPolicyFactory)); + engine.register_factory("demo/cmf-header-injector", Box::new(HeaderInjectorFactory)); } diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index 04d76fab..dca691ca 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -20,13 +20,15 @@ use std::sync::Arc; use async_trait::async_trait; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::{PluginError, PluginViolation}; +use praxis_policy_core::factory::{PluginFactory, PluginInstance}; +use praxis_policy_core::hooks::adapter::TypedHandlerAdapter; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use praxis_policy_core::hooks::{HookMetadata, HookPhase, register_hook_metadata}; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; use cpex_ffi::GenericPayload; @@ -35,8 +37,8 @@ use cpex_ffi::GenericPayload; // --------------------------------------------------------------------------- /// A hook type for FFI callers that send untyped map payloads. -/// The hook *name* varies at registration time (tool_pre_invoke, etc.) -/// but the payload type is always GenericPayload. +/// The hook *name* varies at registration time (demo.tool_pre_invoke, +/// etc.) but the payload type is always GenericPayload. pub struct GenericHook; impl HookTypeDef for GenericHook { @@ -45,6 +47,32 @@ impl HookTypeDef for GenericHook { const NAME: &'static str = "generic"; } +/// Host hook fired before a tool runs. The `cmf.*` hooks carry a typed +/// `MessagePayload`, so a generic-map demo declares its own pair. +pub const HOOK_DEMO_TOOL_PRE_INVOKE: &str = "demo.tool_pre_invoke"; + +/// Host hook fired after a tool runs. +pub const HOOK_DEMO_TOOL_POST_INVOKE: &str = "demo.tool_post_invoke"; + +/// Describe the demo hooks to the engine. The config loader refuses a +/// `hooks:` entry it has no metadata for, so this runs before any config +/// naming them is loaded. +pub fn register_demo_hooks() { + for (name, phase) in [ + (HOOK_DEMO_TOOL_PRE_INVOKE, HookPhase::Pre), + (HOOK_DEMO_TOOL_POST_INVOKE, HookPhase::Post), + ] { + register_hook_metadata( + name, + HookMetadata { + family: Some(GenericHook::NAME), + entity_type: Some("tool"), + phase, + }, + ); + } +} + // --------------------------------------------------------------------------- // Identity Checker // --------------------------------------------------------------------------- @@ -102,11 +130,11 @@ impl PluginFactory for IdentityCheckerFactory { plugin: plugin.clone(), handlers: vec![ ( - "tool_pre_invoke", + HOOK_DEMO_TOOL_PRE_INVOKE, Arc::new(TypedHandlerAdapter::::new(plugin.clone())), ), ( - "tool_post_invoke", + HOOK_DEMO_TOOL_POST_INVOKE, Arc::new(TypedHandlerAdapter::::new(plugin)), ), ], @@ -189,7 +217,7 @@ impl PluginFactory for PiiGuardFactory { Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![( - "tool_pre_invoke", + HOOK_DEMO_TOOL_PRE_INVOKE, Arc::new(TypedHandlerAdapter::::new(plugin)), )], }) @@ -255,11 +283,11 @@ impl PluginFactory for AuditLoggerFactory { plugin: plugin.clone(), handlers: vec![ ( - "tool_pre_invoke", + HOOK_DEMO_TOOL_PRE_INVOKE, Arc::new(TypedHandlerAdapter::::new(plugin.clone())), ), ( - "tool_post_invoke", + HOOK_DEMO_TOOL_POST_INVOKE, Arc::new(TypedHandlerAdapter::::new(plugin)), ), ], @@ -267,9 +295,10 @@ impl PluginFactory for AuditLoggerFactory { } } -/// Register all demo plugin factories on a manager. -pub fn register_demo_factories(manager: &cpex_core::manager::PluginManager) { - manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory)); - manager.register_factory("builtin/pii", Box::new(PiiGuardFactory)); - manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); +/// Register the demo hooks and all demo plugin factories on an engine. +pub fn register_demo_factories(engine: &PolicyEngine) { + register_demo_hooks(); + engine.register_factory("demo/identity", Box::new(IdentityCheckerFactory)); + engine.register_factory("demo/pii", Box::new(PiiGuardFactory)); + engine.register_factory("demo/audit", Box::new(AuditLoggerFactory)); } diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs index 8d3eb59f..41d824d4 100644 --- a/examples/go-demo/ffi/src/lib.rs +++ b/examples/go-demo/ffi/src/lib.rs @@ -10,16 +10,18 @@ // libcpex_ffi separately. // // Exports one C function: `cpex_demo_register_factories()` which -// registers both generic and CMF demo plugin factories: +// registers both generic and CMF demo plugin factories. These are host +// plugins for the demo, not CPEX builtins: // -// Generic (GenericPayload): -// - `builtin/identity` — identity checker -// - `builtin/pii` — PII guard -// - `builtin/audit` — audit logger +// Generic (GenericPayload, on the host hooks `demo.tool_pre_invoke` +// and `demo.tool_post_invoke`): +// - `demo/identity`: identity checker +// - `demo/pii`: PII guard +// - `demo/audit`: audit logger // -// CMF (MessagePayload): -// - `builtin/cmf-tool-policy` — tool permission checking -// - `builtin/cmf-header-injector` — response header injection +// CMF (MessagePayload, on `cmf.tool_pre_invoke` / `cmf.tool_post_invoke`): +// - `demo/cmf-tool-policy`: tool permission checking +// - `demo/cmf-header-injector`: response header injection mod cmf_plugins; mod demo_plugins; @@ -31,28 +33,52 @@ extern crate cpex_ffi; use std::os::raw::c_int; -/// Register demo plugin factories on the manager. +/// Register the demo hooks and plugin factories on the engine. /// -/// Must be called after `cpex_manager_new_default()` and before -/// `cpex_load_config()`. Registers: -/// - `builtin/identity` — identity checker -/// - `builtin/pii` — PII guard -/// - `builtin/audit` — audit logger +/// Must be called after `cpex_engine_new_default()` and before +/// `cpex_load_config()`, because the config loader checks plugin kinds +/// and hook names against what is registered. /// /// # Safety -/// `mgr` must be a valid handle from `cpex_manager_new_default`. -#[no_mangle] +/// `handle` must be a valid handle from `cpex_engine_new_default`. +#[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_demo_register_factories( - mgr: *mut cpex_ffi::CpexManagerInner, + handle: *mut cpex_ffi::CpexEngineInner, ) -> c_int { - let inner = match mgr.as_ref() { - Some(m) => m, + // SAFETY: the caller guarantees `handle` is NULL or a live handle. + let inner = match unsafe { handle.as_ref() } { + Some(h) => h, None => return -1, }; - // `register_factory` takes `&self`; `&inner.manager` deref-coerces - // from `Arc` to `&PluginManager`. - demo_plugins::register_demo_factories(&inner.manager); - cmf_plugins::register_cmf_factories(&inner.manager); + // `register_factory` takes `&self`; `&inner.engine` deref-coerces + // from `Arc` to `&PolicyEngine`. + demo_plugins::register_demo_factories(&inner.engine); + cmf_plugins::register_cmf_factories(&inner.engine); 0 } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use praxis_policy_core::engine::PolicyEngine; + + /// Both demo configs load once the demo hooks and factories are + /// registered, so the Go binaries do not fail on startup. + #[test] + fn demo_configs_load() { + let configs = [ + ("plugins.yaml", include_str!("../../plugins.yaml")), + ("cmf_plugins.yaml", include_str!("../../cmf_plugins.yaml")), + ]; + for (name, yaml) in configs { + let engine = Arc::new(PolicyEngine::default()); + crate::demo_plugins::register_demo_factories(&engine); + crate::cmf_plugins::register_cmf_factories(&engine); + engine + .load_config_yaml(yaml) + .unwrap_or_else(|e| panic!("{name} failed to load: {e}")); + } + } +} diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod index 7c4164ae..3781b550 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.2.3 +require github.com/contextforge-org/cpex/go/cpex v0.3.0 require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect diff --git a/examples/go-demo/main.go b/examples/go-demo/main.go index 926b8164..b4e9751e 100644 --- a/examples/go-demo/main.go +++ b/examples/go-demo/main.go @@ -8,9 +8,9 @@ // Demonstrates the full CPEX plugin pipeline from Go using // GenericPayload (untyped map payloads): // -// 1. Create a PluginManager via the Go SDK +// 1. Create a PolicyEngine via the Go SDK // 2. Register demo plugin factories (identity, PII, audit) -// 3. Load YAML config with routing rules and policy groups +// 3. Load YAML config (hook dispatch) // 4. Invoke hooks with MetaExtension for route resolution // 5. Inspect results (allow/deny, violations) // 6. Thread ContextTable between pre-invoke and post-invoke @@ -30,7 +30,7 @@ package main #cgo darwin LDFLAGS: -framework CoreFoundation -framework Security #include -int cpex_demo_register_factories(void* mgr); +int cpex_demo_register_factories(void* engine); */ import "C" @@ -46,16 +46,16 @@ func main() { fmt.Println("=== CPEX Go Demo ===") fmt.Println() - // --- Create manager --- - mgr, err := cpex.NewPluginManagerDefault() + // --- Create engine --- + engine, err := cpex.NewPolicyEngineDefault() if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) os.Exit(1) } - defer mgr.Shutdown() + defer engine.Shutdown() // --- Register demo factories via callback --- - err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + err = engine.RegisterFactories(func(handle unsafe.Pointer) error { rc := C.cpex_demo_register_factories(handle) if rc != 0 { return fmt.Errorf("cpex_demo_register_factories returned %d", rc) @@ -74,21 +74,21 @@ func main() { os.Exit(1) } - if err := mgr.LoadConfig(string(yaml)); err != nil { + if err := engine.LoadConfig(string(yaml)); err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) os.Exit(1) } // --- Initialize --- - if err := mgr.Initialize(); err != nil { + if err := engine.Initialize(); err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) os.Exit(1) } - fmt.Printf("Plugins loaded: %d\n", mgr.PluginCount()) - fmt.Printf("Hooks: tool_pre_invoke=%v tool_post_invoke=%v\n\n", - mgr.HasHooksFor("tool_pre_invoke"), - mgr.HasHooksFor("tool_post_invoke"), + fmt.Printf("Plugins loaded: %d\n", engine.PluginCount()) + fmt.Printf("Hooks: demo.tool_pre_invoke=%v demo.tool_post_invoke=%v\n\n", + engine.HasHooksFor("demo.tool_pre_invoke"), + engine.HasHooksFor("demo.tool_post_invoke"), ) // ----------------------------------------------------------------------- @@ -97,7 +97,7 @@ func main() { fmt.Println("=== Scenario 1: get_compensation (no PII clearance) ===") fmt.Println() - result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", + result, ct, bg, err := engine.InvokeByName("demo.tool_pre_invoke", cpex.PayloadGeneric, map[string]any{ "tool_name": "get_compensation", @@ -127,7 +127,7 @@ func main() { fmt.Println("=== Scenario 2: get_compensation (with PII clearance) ===") fmt.Println() - result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + result, ct, bg, err = engine.InvokeByName("demo.tool_pre_invoke", cpex.PayloadGeneric, map[string]any{ "tool_name": "get_compensation", @@ -155,7 +155,7 @@ func main() { fmt.Println(" --- post-invoke for get_compensation ---") fmt.Println() - result2, ct2, bg2, err := mgr.InvokeByName("tool_post_invoke", + result2, ct2, bg2, err := engine.InvokeByName("demo.tool_post_invoke", cpex.PayloadGeneric, map[string]any{ "tool_name": "get_compensation", @@ -184,7 +184,7 @@ func main() { fmt.Println("=== Scenario 3: list_departments (non-PII tool) ===") fmt.Println() - result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + result, ct, bg, err = engine.InvokeByName("demo.tool_pre_invoke", cpex.PayloadGeneric, map[string]any{ "tool_name": "list_departments", @@ -212,7 +212,7 @@ func main() { fmt.Println("=== Scenario 4: list_departments (no user identity) ===") fmt.Println() - result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + result, ct, bg, err = engine.InvokeByName("demo.tool_pre_invoke", cpex.PayloadGeneric, map[string]any{ "tool_name": "list_departments", diff --git a/examples/go-demo/plugins.yaml b/examples/go-demo/plugins.yaml index f7975cbb..4a84bf3e 100644 --- a/examples/go-demo/plugins.yaml +++ b/examples/go-demo/plugins.yaml @@ -5,55 +5,37 @@ # # CPEX Go Demo — plugin configuration # -# Three plugins with routing rules that demonstrate: -# - Identity validation on every invocation (global policy) -# - PII guarding on tagged tools (tag-based policy group) +# Three host demo plugins on the demo's own hooks (demo.tool_pre_invoke and +# demo.tool_post_invoke, registered by the demo FFI crate): +# - Identity validation on every invocation +# - PII guarding on tools tagged `pii` (the plugin reads the tag) # - Audit logging on all tools (fire-and-forget) +# +# Hook dispatch: each plugin fires at the hooks it declares, ordered by +# priority within its mode. No routes, groups or global policy. -plugin_settings: - routing_enabled: true +engine_settings: + dispatch: hooks plugin_timeout: 30 -global: - policies: - all: - plugins: [identity-checker] - pii: - plugins: [pii-guard] - plugins: - name: identity-checker - kind: builtin/identity - hooks: [tool_pre_invoke, tool_post_invoke] + kind: demo/identity + hooks: [demo.tool_pre_invoke, demo.tool_post_invoke] mode: sequential priority: 10 on_error: fail - name: pii-guard - kind: builtin/pii - hooks: [tool_pre_invoke] + kind: demo/pii + hooks: [demo.tool_pre_invoke] mode: sequential priority: 20 on_error: fail - name: audit-logger - kind: builtin/audit - hooks: [tool_pre_invoke, tool_post_invoke] + kind: demo/audit + hooks: [demo.tool_pre_invoke, demo.tool_post_invoke] mode: fire_and_forget priority: 100 on_error: ignore - -routes: - - tool: get_compensation - meta: - tags: [pii, hr] - plugins: - - audit-logger - - - tool: list_departments - plugins: - - audit-logger - - - tool: "*" - plugins: - - audit-logger diff --git a/examples/tutorial/Cargo.toml b/examples/tutorial/Cargo.toml index 5fb0f157..fd8c7722 100644 --- a/examples/tutorial/Cargo.toml +++ b/examples/tutorial/Cargo.toml @@ -24,12 +24,10 @@ authors.workspace = true publish = false [dependencies] -# The host facade with the bundled builtins (jwt, oauth, pii, audit, -# cedar, cel, elicitation-ciba). Path dependency, the tutorial is not -# published, so it does not use the workspace version table. -cpex = { path = "../../crates/cpex", features = ["builtins"] } -cpex-core = { workspace = true } -cpex-sdk = { workspace = true } +# The host facade, which bundles every stable builtin. Path dependency, the +# tutorial is not published, so it does not use the workspace version table. +cpex = { path = "../../crates/cpex" } +praxis-policy-core = { workspace = true } tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } async-trait = { workspace = true } diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 99d23133..5705279f 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -15,8 +15,10 @@ cargo run -p cpex-tutorial --example m01_hello -- --check # scripted assertions | `src/mediate.rs` | `mediate()`, the enforcement loop a host owns, wrapped in one call. **Harness code, not a CPEX API** (see the note at the top of the file). | | `src/backends.rs` | Fake HR / repo / email tools. They hold data and do no enforcement; policy in front of them decides who sees what. | | `src/idp.rs` | Mint a token from the tutorial Keycloak realm; wait for it to be ready. | +| `src/audit.rs` | A one-line audit plugin (`kind: tutorial/audit-line`) used by module 4 and the capstone. It prints the tool name and argument keys, never argument values. | | `src/approvals.rs` | A tiny `curl`-driven approval channel for the elicitation module. | | `src/ui.rs` | Shared console formatting and the `--check` assertion helpers. | +| `src/lib.rs` | `install_http_transport()`, the HTTP transport every module installs. It allows private destinations so the builtins can reach the IdP on `localhost`. **Not a production setting.** | | `examples/mNN_*.rs` | One binary per tutorial module. | | `policies/mNN.yaml` | The APL each module loads. **These are yours to edit**; that is the point. Reset with `git checkout -- examples/tutorial/policies`. | | `idp/` | `docker-compose.yml` + `realm-export.json` for the tutorial Keycloak. | @@ -30,6 +32,8 @@ docker compose -f examples/tutorial/idp/docker-compose.yml up -d # ~30s first docker compose -f examples/tutorial/idp/docker-compose.yml down # resets everything ``` +The identity, delegation and approval builtins reach Keycloak through the HTTP transport the host installs, and each of those plugins declares the `perform_http` capability in its policy. The tutorial transport allows private and loopback destinations because the IdP runs on `localhost`; a production host keeps the default (`cpex::install_default_http_transport`) or brings its own. + The realm takes a few seconds to import. Wait for it before running an IdP-backed module (a call returning `auth.jwks_unavailable` means it was still booting): ```bash diff --git a/examples/tutorial/examples/capstone.rs b/examples/tutorial/examples/capstone.rs index 09da63f4..8c5884c7 100644 --- a/examples/tutorial/examples/capstone.rs +++ b/examples/tutorial/examples/capstone.rs @@ -22,11 +22,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller, Outcome}; +use cpex_tutorial::{Caller, Outcome, mediate}; use serde_json::json; @@ -61,11 +61,14 @@ async fn main() { println!("(running the no-delegation variant)\n"); } - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(policy) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + cpex_tutorial::audit::register(&engine); + engine + .load_config_yaml(policy) .expect("capstone policy should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = token("alice").await; let dana = token("dana").await; @@ -78,7 +81,7 @@ async fn main() { ui::scenario("alice (hr, view_ssn) → get_compensation"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&alice).in_session("s-alice"), "get_compensation", json!({ "employee_id": "e-1001" }), @@ -90,7 +93,7 @@ async fn main() { ui::scenario("dana (hr, no view_ssn) → get_compensation (SSN redacted)"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&dana).in_session("s-dana"), "get_compensation", json!({ "employee_id": "e-1001" }), @@ -102,7 +105,7 @@ async fn main() { ui::scenario("evan (engineer) → get_compensation (denied, not HR)"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&evan).in_session("s-evan"), "get_compensation", json!({ "employee_id": "e-1001" }), @@ -117,7 +120,7 @@ async fn main() { ui::scenario("evan (engineer) → search_repos internal (allowed)"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&evan).in_session("s-evan"), "search_repos", json!({ "visibility": "internal" }), @@ -129,7 +132,7 @@ async fn main() { ui::scenario("sam (security) → search_repos public (allowed)"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&sam).in_session("s-sam"), "search_repos", json!({ "visibility": "public" }), @@ -144,7 +147,7 @@ async fn main() { ui::scenario("dana, fresh session → send_email (allowed: nothing read yet)"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&dana).in_session("s-clean"), "send_email", json!({ "to": "team@corp.example", "subject": "sync" }), @@ -156,7 +159,7 @@ async fn main() { ui::scenario("dana, same session as her HR read → send_email (write-down blocked)"); let o = mediate( - &mgr, + &engine, &Caller::with_token(&dana).in_session("s-dana"), "send_email", json!({ "to": "team@corp.example", "subject": "sync" }), @@ -166,6 +169,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("One policy, one unchanged application. Identity, permission, and session history produced every outcome."); + println!( + "One policy, one unchanged application. Identity, permission, and session history produced every outcome." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m01_hello.rs b/examples/tutorial/examples/m01_hello.rs index ab18456a..8726e7d9 100644 --- a/examples/tutorial/examples/m01_hello.rs +++ b/examples/tutorial/examples/m01_hello.rs @@ -8,7 +8,7 @@ // cargo run -p cpex-tutorial --example m01_hello // cargo run -p cpex-tutorial --example m01_hello -- --check // -// The smallest possible CPEX host: build a PluginManager, install the +// The smallest possible CPEX host: build a PolicyEngine, install the // builtins, load a policy, and dispatch two operations through it. No IdP // yet, every caller is anonymous. You will see one route deny (it // requires an authenticated caller) and one route allow (it has no rule). @@ -17,10 +17,10 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -32,12 +32,16 @@ const POLICY: &str = include_str!("../policies/m01.yaml"); async fn main() { ui::module_banner("Module 1: Hello, enforcement"); - // --- Set up the enforcement point (3 lines a real host also writes) --- - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + // --- Set up the enforcement point (the same steps a real host writes) --- + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + // The HTTP transport builtins use to reach an IdP. Module 1 calls no IdP, + // but every module installs it the same way; see `install_http_transport`. + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m01.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); // Everyone is anonymous in module 1, no token. let caller = Caller::anonymous(); @@ -47,7 +51,7 @@ async fn main() { // anonymous caller, so the backend never runs. --- ui::scenario("anonymous → get_compensation (route requires authentication)"); let outcome = mediate( - &mgr, + &engine, &caller, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -61,7 +65,7 @@ async fn main() { // returns the backend's result. --- ui::scenario("anonymous → search_repos (route has no rule)"); let outcome = mediate( - &mgr, + &engine, &caller, "search_repos", json!({ "visibility": "public" }), diff --git a/examples/tutorial/examples/m02_identity.rs b/examples/tutorial/examples/m02_identity.rs index abf5d584..13bb8da5 100644 --- a/examples/tutorial/examples/m02_identity.rs +++ b/examples/tutorial/examples/m02_identity.rs @@ -20,11 +20,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -34,11 +34,13 @@ const POLICY: &str = include_str!("../policies/m02.yaml"); async fn main() { ui::module_banner("Module 2: Who's calling? (Identity)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m02.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = match idp::mint_token("alice", "alice").await { Ok(t) => Caller::with_token(t), @@ -59,7 +61,7 @@ async fn main() { ui::scenario("alice (hr) → get_compensation"); let outcome = mediate( - &mgr, + &engine, &alice, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -71,7 +73,7 @@ async fn main() { ui::scenario("evan (engineer) → get_compensation (fails require(role.hr))"); let outcome = mediate( - &mgr, + &engine, &evan, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -84,7 +86,7 @@ async fn main() { ui::scenario("garbage token → get_compensation (rejected at validation)"); let bogus = Caller::with_token("not.a.jwt"); let outcome = mediate( - &mgr, + &engine, &bogus, "get_compensation", json!({ "employee_id": "e-1001" }), diff --git a/examples/tutorial/examples/m03_shaping.rs b/examples/tutorial/examples/m03_shaping.rs index 94e7a67e..d5ae9572 100644 --- a/examples/tutorial/examples/m03_shaping.rs +++ b/examples/tutorial/examples/m03_shaping.rs @@ -17,10 +17,10 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller, Outcome}; +use cpex_tutorial::{Caller, Outcome, mediate}; use serde_json::json; @@ -30,18 +30,20 @@ const POLICY: &str = include_str!("../policies/m03.yaml"); async fn main() { ui::module_banner("Module 3: Shaping data"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m03.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let caller = Caller::anonymous(); let mut all_passed = true; ui::scenario("anonymous → get_compensation (result pipeline redacts ssn + salary, masks id)"); let outcome = mediate( - &mgr, + &engine, &caller, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -55,17 +57,17 @@ async fn main() { // Field-level verification runs under --check only, so an interactive // "Try it" run (e.g. minting alice, where the SSN is meant to appear) // stays quiet; the printed result above already shows what was shaped. - if ui::check_mode() { - if let Outcome::Allowed { result } = &outcome { - let ssn = result.get("ssn").and_then(|v| v.as_str()).unwrap_or(""); - let id = result - .get("employee_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let ssn_redacted = ssn != "521-38-7710"; - let id_masked = id != "e-1001"; - all_passed &= ssn_redacted && id_masked; - } + if ui::check_mode() + && let Outcome::Allowed { result } = &outcome + { + let ssn = result.get("ssn").and_then(|v| v.as_str()).unwrap_or(""); + let id = result + .get("employee_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ssn_redacted = ssn != "521-38-7710"; + let id_masked = id != "e-1001"; + all_passed &= ssn_redacted && id_masked; } println!(); diff --git a/examples/tutorial/examples/m04_effects.rs b/examples/tutorial/examples/m04_effects.rs index e40b62f2..f8117946 100644 --- a/examples/tutorial/examples/m04_effects.rs +++ b/examples/tutorial/examples/m04_effects.rs @@ -19,10 +19,10 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller, Outcome}; +use cpex_tutorial::{Caller, Outcome, mediate}; use serde_json::json; @@ -32,11 +32,14 @@ const POLICY: &str = include_str!("../policies/m04.yaml"); async fn main() { ui::module_banner("Module 4: Effects and sequencing"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + cpex_tutorial::audit::register(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m04.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let caller = Caller::anonymous(); let mut all_passed = true; @@ -45,7 +48,7 @@ async fn main() { // deny() guard halts with code `email.external_blocked`. --- ui::scenario("send_email to an external recipient (custom deny guard halts)"); let outcome = mediate( - &mgr, + &engine, &caller, "send_email", json!({ "to": "attacker@evil.example", "subject": "hi", "external": true }), @@ -69,7 +72,7 @@ async fn main() { // anonymous caller with a different code. Proof of ordering. --- ui::scenario("send_email to an internal recipient (guard passes, auth check halts)"); let outcome = mediate( - &mgr, + &engine, &caller, "send_email", json!({ "to": "coworker@corp.example", "subject": "hi", "external": false }), diff --git a/examples/tutorial/examples/m05_pdp.rs b/examples/tutorial/examples/m05_pdp.rs index e27cfff2..69eb0d8c 100644 --- a/examples/tutorial/examples/m05_pdp.rs +++ b/examples/tutorial/examples/m05_pdp.rs @@ -17,11 +17,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -41,11 +41,13 @@ async fn token(user: &str) -> Caller { async fn main() { ui::module_banner("Module 5: Delegating decisions (PDP)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m05.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let evan = token("evan").await; let sam = token("sam").await; @@ -53,7 +55,7 @@ async fn main() { ui::scenario("evan (engineer) → search_repos internal (CEL allows)"); let o = mediate( - &mgr, + &engine, &evan, "search_repos", json!({ "visibility": "internal" }), @@ -65,7 +67,7 @@ async fn main() { ui::scenario("evan (engineer) → search_repos public (CEL denies: engineers internal-only)"); let o = mediate( - &mgr, + &engine, &evan, "search_repos", json!({ "visibility": "public" }), @@ -77,7 +79,7 @@ async fn main() { ui::scenario("sam (security) → search_repos public (CEL allows: security reads any)"); let o = mediate( - &mgr, + &engine, &sam, "search_repos", json!({ "visibility": "public" }), @@ -87,6 +89,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - println!("One CEL expression captured a rule that mixes role and argument. The PDP decided; CPEX enforced."); + println!( + "One CEL expression captured a rule that mixes role and argument. The PDP decided; CPEX enforced." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m06_delegation.rs b/examples/tutorial/examples/m06_delegation.rs index b50f13d1..cdd6abd1 100644 --- a/examples/tutorial/examples/m06_delegation.rs +++ b/examples/tutorial/examples/m06_delegation.rs @@ -19,11 +19,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -43,11 +43,13 @@ async fn token(user: &str) -> Caller { async fn main() { ui::module_banner("Module 6: Scoped credentials (Delegation)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m06.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = token("alice").await; let evan = token("evan").await; @@ -55,7 +57,7 @@ async fn main() { ui::scenario("alice (hr) → get_compensation (delegate mints a workday-api token, then allow)"); let o = mediate( - &mgr, + &engine, &alice, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -67,7 +69,7 @@ async fn main() { ui::scenario("evan (engineer) → get_compensation (denied at require(role.hr), no delegation)"); let o = mediate( - &mgr, + &engine, &evan, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -77,6 +79,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("Delegation is a policy step: the caller's token was exchanged for a downstream-scoped one before the backend call."); + println!( + "Delegation is a policy step: the caller's token was exchanged for a downstream-scoped one before the backend call." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m07_tainting.rs b/examples/tutorial/examples/m07_tainting.rs index db1b05b7..3e4ae3fd 100644 --- a/examples/tutorial/examples/m07_tainting.rs +++ b/examples/tutorial/examples/m07_tainting.rs @@ -17,11 +17,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -31,11 +31,13 @@ const POLICY: &str = include_str!("../policies/m07.yaml"); async fn main() { ui::module_banner("Module 7: Information flow (Tainting)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m07.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let token = match idp::mint_token("alice", "alice").await { Ok(t) => t, @@ -50,7 +52,7 @@ async fn main() { let clean = Caller::with_token(token.clone()).in_session("session-clean"); ui::scenario("alice, fresh session → send_email (nothing tainted yet)"); let o = mediate( - &mgr, + &engine, &clean, "send_email", json!({ "to": "coworker@corp.example", "subject": "lunch" }), @@ -64,7 +66,7 @@ async fn main() { let working = Caller::with_token(token.clone()).in_session("session-hr-work"); ui::scenario("alice, session-hr-work → get_compensation (taints session 'secret')"); let o = mediate( - &mgr, + &engine, &working, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -77,7 +79,7 @@ async fn main() { // ...then the SAME send_email is denied, because the session is tainted. ui::scenario("alice, session-hr-work → send_email (write-down blocked)"); let o = mediate( - &mgr, + &engine, &working, "send_email", json!({ "to": "coworker@corp.example", "subject": "lunch" }), @@ -87,6 +89,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("Identical send_email call: allowed in the clean session, denied after the same session read secret data."); + println!( + "Identical send_email call: allowed in the clean session, denied after the same session read secret data." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m08_elicitation.rs b/examples/tutorial/examples/m08_elicitation.rs index 8a5a0260..b6b2e4e0 100644 --- a/examples/tutorial/examples/m08_elicitation.rs +++ b/examples/tutorial/examples/m08_elicitation.rs @@ -29,22 +29,21 @@ use std::time::Duration; use async_trait::async_trait; -use cpex::PluginManager; -use cpex_core::elicitation::{ +use cpex::PolicyEngine; +use cpex::prelude::{ + Extensions, HookHandler, Plugin, PluginConfig, PluginContext, PluginError, PluginFactory, + PluginInstance, PluginResult, TypedHandlerAdapter, +}; +use praxis_policy_core::elicitation::{ ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, ElicitationStatusKind, }; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_sdk::{ - Extensions, HookHandler, Plugin, PluginConfig, PluginContext, PluginError, PluginResult, -}; use cpex_tutorial::approvals::{ApprovalChannel, Status}; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller, Outcome}; +use cpex_tutorial::{Caller, Outcome, mediate}; use serde_json::json; @@ -151,17 +150,19 @@ async fn main() { channel.serve(APPROVAL_PORT); } - let mgr = Arc::new(PluginManager::default()); - mgr.register_factory( + let engine = Arc::new(PolicyEngine::default()); + engine.register_factory( "approval-channel", Box::new(ChannelApproverFactory { channel: channel.clone(), }), ); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m08.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let evan = match idp::mint_token("evan", "evan").await { Ok(t) => Caller::with_token(t), @@ -175,7 +176,7 @@ async fn main() { // --- First attempt: the call suspends awaiting approval. --- ui::scenario("evan → send_email (first attempt: suspends for manager approval)"); let outcome = mediate( - &mgr, + &engine, &evan, "send_email", json!({ "to": "client@partner.example", "subject": "proposal" }), @@ -225,7 +226,7 @@ async fn main() { ui::scenario("evan → send_email (retry with the approval: resumes and runs)"); let resumed = evan.clone().resuming(&elicitation_id); let outcome = mediate( - &mgr, + &engine, &resumed, "send_email", json!({ "to": "client@partner.example", "subject": "proposal" }), @@ -235,9 +236,13 @@ async fn main() { ui::print_outcome(&outcome); if outcome.is_allowed() { - println!("The operation suspended until a human approved, then resumed. The agent could not proceed alone."); + println!( + "The operation suspended until a human approved, then resumed. The agent could not proceed alone." + ); } else { - println!("The human denied the approval, so the suspended operation ended denied. The agent could not proceed alone."); + println!( + "The human denied the approval, so the suspended operation ended denied. The agent could not proceed alone." + ); } // --check drives the approve path, so the resumed call must be allowed. all_passed &= ui::expect(&outcome, true); diff --git a/examples/tutorial/examples/m09_custom_plugin.rs b/examples/tutorial/examples/m09_custom_plugin.rs index 06a06723..cda30a72 100644 --- a/examples/tutorial/examples/m09_custom_plugin.rs +++ b/examples/tutorial/examples/m09_custom_plugin.rs @@ -13,25 +13,22 @@ // like a builtin. This one is a "business hours" guard: it denies a call // whose `hour` argument falls outside the open/close window in its config. // -// The traits come from cpex-sdk (the lean plugin-author crate). The -// factory and handler-adapter plumbing come from cpex-core, the same -// pattern every builtin uses. +// The traits, the factory and the handler-adapter plumbing all come from +// `cpex::prelude`, the same pattern every builtin uses. use std::sync::Arc; use async_trait::async_trait; -use cpex::PluginManager; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_sdk::{ +use cpex::PolicyEngine; +use cpex::prelude::{ CmfHook, Extensions, HookHandler, MessagePayload, Plugin, PluginConfig, PluginContext, - PluginError, PluginResult, PluginViolation, + PluginError, PluginFactory, PluginInstance, PluginResult, PluginViolation, TypedHandlerAdapter, }; use cpex_tutorial::backends; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller, Outcome}; +use cpex_tutorial::{Caller, Outcome, mediate}; use serde_json::json; @@ -119,21 +116,23 @@ impl PluginFactory for BusinessHoursFactory { async fn main() { ui::module_banner("Module 9: Write your own plugin"); - let mgr = Arc::new(PluginManager::default()); + let engine = Arc::new(PolicyEngine::default()); // Register the custom factory BEFORE loading config, so the APL visitor // can resolve `kind: business-hours` to it. - mgr.register_factory("business-hours", Box::new(BusinessHoursFactory)); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + engine.register_factory("business-hours", Box::new(BusinessHoursFactory)); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m09.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let caller = Caller::anonymous(); let mut all_passed = true; ui::scenario("get_compensation at 10:00 (within 9-17 window)"); let o = mediate( - &mgr, + &engine, &caller, "get_compensation", json!({ "employee_id": "e-1001", "hour": 10 }), @@ -145,7 +144,7 @@ async fn main() { ui::scenario("get_compensation at 22:00 (outside the window)"); let o = mediate( - &mgr, + &engine, &caller, "get_compensation", json!({ "employee_id": "e-1001", "hour": 22 }), diff --git a/examples/tutorial/examples/m10_testing.rs b/examples/tutorial/examples/m10_testing.rs index bd7f69a7..651b9b3e 100644 --- a/examples/tutorial/examples/m10_testing.rs +++ b/examples/tutorial/examples/m10_testing.rs @@ -16,10 +16,10 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -29,10 +29,12 @@ const M04: &str = include_str!("../policies/m04.yaml"); async fn main() { ui::module_banner("Module 10: Testing your policy"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(M04).expect("policy should load"); - mgr.initialize().await.expect("initialize"); + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + cpex_tutorial::audit::register(&engine); + engine.load_config_yaml(M04).expect("policy should load"); + engine.initialize().await.expect("initialize"); // A table of expectations. Each row is a policy assertion. let cases = [ @@ -54,7 +56,7 @@ async fn main() { "{tool} {args} → expect {}", if want_allowed { "ALLOW" } else { "DENY" } )); - let outcome = mediate(&mgr, &Caller::anonymous(), tool, args, |a| { + let outcome = mediate(&engine, &Caller::anonymous(), tool, args, |a| { backends::dispatch(tool, a) }) .await; diff --git a/examples/tutorial/examples/m11_groups.rs b/examples/tutorial/examples/m11_groups.rs index b3ea95d3..229f7172 100644 --- a/examples/tutorial/examples/m11_groups.rs +++ b/examples/tutorial/examples/m11_groups.rs @@ -19,11 +19,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -43,11 +43,13 @@ async fn token(user: &str) -> Caller { async fn main() { ui::module_banner("Module 11: Organizing policy (Groups)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m11.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = token("alice").await; let evan = token("evan").await; @@ -57,7 +59,7 @@ async fn main() { "alice (hr) → get_compensation (group resolves her token, require(role.hr) passes)", ); let o = mediate( - &mgr, + &engine, &alice, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -67,9 +69,11 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - ui::scenario("evan (engineer) → get_compensation (resolved by the same group, denied at require(role.hr))"); + ui::scenario( + "evan (engineer) → get_compensation (resolved by the same group, denied at require(role.hr))", + ); let o = mediate( - &mgr, + &engine, &evan, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -82,7 +86,7 @@ async fn main() { // The engineering route: same group, a different requirement. ui::scenario("evan (engineer) → search_repos (same group, require(role.engineer) passes)"); let o = mediate( - &mgr, + &engine, &evan, "search_repos", json!({ "visibility": "internal" }), @@ -94,7 +98,7 @@ async fn main() { ui::scenario("alice (hr) → search_repos (denied at require(role.engineer))"); let o = mediate( - &mgr, + &engine, &alice, "search_repos", json!({ "visibility": "internal" }), @@ -108,7 +112,7 @@ async fn main() { "alice (hr) → send_email (group resolves her token; require(authenticated) passes)", ); let o = mediate( - &mgr, + &engine, &alice, "send_email", json!({ "to": "coworker@corp.example", "subject": "lunch" }), @@ -118,6 +122,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - println!("One group carried the shared resolver for all three routes; each route added only its own authorization."); + println!( + "One group carried the shared resolver for all three routes; each route added only its own authorization." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m12_subjects.rs b/examples/tutorial/examples/m12_subjects.rs index e39c8838..c2666805 100644 --- a/examples/tutorial/examples/m12_subjects.rs +++ b/examples/tutorial/examples/m12_subjects.rs @@ -19,11 +19,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -43,11 +43,13 @@ async fn token(user: &str) -> Caller { async fn main() { ui::module_banner("Module 12: Delegation subjects (who the call speaks for)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m12.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = token("alice").await; let anon = Caller::anonymous(); @@ -56,7 +58,7 @@ async fn main() { // subject: user, on behalf of the caller. Needs a caller token to exchange. ui::scenario("alice → get_compensation (subject: user, exchanges alice's token)"); let o = mediate( - &mgr, + &engine, &alice, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -70,7 +72,7 @@ async fn main() { "anonymous → get_compensation (subject: user, no token to exchange, delegation fails)", ); let o = mediate( - &mgr, + &engine, &anon, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -85,7 +87,7 @@ async fn main() { "anonymous → search_repos (subject: this_workload, gateway mints via client_credentials)", ); let o = mediate( - &mgr, + &engine, &anon, "search_repos", json!({ "visibility": "internal" }), @@ -95,9 +97,11 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - ui::scenario("alice → search_repos (subject: this_workload, same result: the caller's identity is not used)"); + ui::scenario( + "alice → search_repos (subject: this_workload, same result: the caller's identity is not used)", + ); let o = mediate( - &mgr, + &engine, &alice, "search_repos", json!({ "visibility": "internal" }), @@ -107,6 +111,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - println!("`subject:` chooses whose authority the downstream token carries: the caller (user) or the gateway itself (this_workload)."); + println!( + "`subject:` chooses whose authority the downstream token carries: the caller (user) or the gateway itself (this_workload)." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m13_client.rs b/examples/tutorial/examples/m13_client.rs index def22e6b..99e10470 100644 --- a/examples/tutorial/examples/m13_client.rs +++ b/examples/tutorial/examples/m13_client.rs @@ -19,11 +19,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -44,11 +44,13 @@ async fn agent_caller() -> Caller { async fn main() { ui::module_banner("Module 13: Delegation subject: client (the agent scopes its own token)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m13.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let agent = agent_caller().await; let anon = Caller::anonymous(); @@ -59,7 +61,7 @@ async fn main() { "agent (cpex-agent client) → search_repos (subject: client, scopes the agent's own token)", ); let o = mediate( - &mgr, + &engine, &agent, "search_repos", json!({ "visibility": "internal" }), @@ -73,7 +75,7 @@ async fn main() { "anonymous → search_repos (subject: client, no client token to exchange, delegation fails)", ); let o = mediate( - &mgr, + &engine, &anon, "search_repos", json!({ "visibility": "internal" }), @@ -83,6 +85,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("`subject: client` scopes the CALLER's own client token: an agent acting as itself, not on behalf of a user."); + println!( + "`subject: client` scopes the CALLER's own client token: an agent acting as itself, not on behalf of a user." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m14_passthrough.rs b/examples/tutorial/examples/m14_passthrough.rs index 8c37fa5f..8afed0d9 100644 --- a/examples/tutorial/examples/m14_passthrough.rs +++ b/examples/tutorial/examples/m14_passthrough.rs @@ -17,11 +17,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -41,11 +41,13 @@ async fn token(user: &str) -> Caller { async fn main() { ui::module_banner("Module 14: Passthrough (forward the caller's token, mint nothing)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m14.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = token("alice").await; let anon = Caller::anonymous(); @@ -55,7 +57,7 @@ async fn main() { // downstream. ui::scenario("alice → search_repos (validated and forwarded, no token minted)"); let o = mediate( - &mgr, + &engine, &alice, "search_repos", json!({ "visibility": "internal" }), @@ -68,7 +70,7 @@ async fn main() { // Passthrough still needs a valid inbound token to forward. ui::scenario("anonymous → search_repos (no token to forward, require(authenticated) denies)"); let o = mediate( - &mgr, + &engine, &anon, "search_repos", json!({ "visibility": "internal" }), diff --git a/examples/tutorial/examples/m15_dual_principal.rs b/examples/tutorial/examples/m15_dual_principal.rs index ab7bbc82..602447a2 100644 --- a/examples/tutorial/examples/m15_dual_principal.rs +++ b/examples/tutorial/examples/m15_dual_principal.rs @@ -19,11 +19,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -50,11 +50,13 @@ async fn agent_token() -> String { async fn main() { ui::module_banner("Module 15: Dual-principal delegation (who authorized vs. who acted)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m15.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = user_token("alice").await; let agent = agent_token().await; @@ -67,7 +69,7 @@ async fn main() { ); let both = Caller::with_token(agent.clone()).with_credential("X-User-Token", alice.clone()); let o = mediate( - &mgr, + &engine, &both, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -82,7 +84,7 @@ async fn main() { ui::scenario("alice only, no agent token → get_compensation (actor credential missing)"); let user_only = Caller::anonymous().with_credential("X-User-Token", alice.clone()); let o = mediate( - &mgr, + &engine, &user_only, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -99,7 +101,7 @@ async fn main() { "sync_index (subject: this_workload + actor: client) → rejected as an invalid combo", ); let o = mediate( - &mgr, + &engine, &Caller::anonymous(), "sync_index", json!({}), @@ -109,6 +111,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("`subject:` is who the token speaks FOR; `actor:` is who is DOING it. Both principals ride one exchange."); + println!( + "`subject:` is who the token speaks FOR; `actor:` is who is DOING it. Both principals ride one exchange." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m16_workload.rs b/examples/tutorial/examples/m16_workload.rs index b29ac595..5f7cf6c2 100644 --- a/examples/tutorial/examples/m16_workload.rs +++ b/examples/tutorial/examples/m16_workload.rs @@ -22,11 +22,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -37,11 +37,13 @@ const SPIFFE_ID: &str = "spiffe://cpex.tutorial/agent/hr-copilot"; async fn main() { ui::module_banner("Module 16: Workload identity (the agent proves itself with an SVID)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m16.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); // Mint the agent's SVID off SPIRE. This is the agent's identity credential // an ES256 JWT signed by SPIRE, audience = the tutorial realm issuer. @@ -58,7 +60,7 @@ async fn main() { ); let agent = Caller::anonymous().with_credential("X-Workload-Token", svid); let o = mediate( - &mgr, + &engine, &agent, "search_repos", json!({ "visibility": "internal" }), @@ -72,7 +74,7 @@ async fn main() { // anonymous request has no identity to broker from. ui::scenario("anonymous → search_repos (no SVID, so nothing to broker from)"); let o = mediate( - &mgr, + &engine, &Caller::anonymous(), "search_repos", json!({ "visibility": "internal" }), diff --git a/examples/tutorial/examples/m17_federation.rs b/examples/tutorial/examples/m17_federation.rs index e945d125..ffa4190d 100644 --- a/examples/tutorial/examples/m17_federation.rs +++ b/examples/tutorial/examples/m17_federation.rs @@ -18,11 +18,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -40,11 +40,13 @@ fn or_exit(r: Result) -> String { async fn main() { ui::module_banner("Module 17: Multi-issuer identity (trust more than one IdP)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m17.yaml should load"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); // Home realm: alice, minted from cpex-tutorial. let alice = Caller::with_token(or_exit(idp::mint_token("alice", "alice").await)); @@ -61,7 +63,7 @@ async fn main() { ui::scenario("alice (home realm cpex-tutorial) → get_compensation (issuer #1, role.hr)"); let o = mediate( - &mgr, + &engine, &alice, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -75,7 +77,7 @@ async fn main() { "pat (partner realm cpex-partner) → get_compensation (issuer #2, validated on ITS keys)", ); let o = mediate( - &mgr, + &engine, &pat, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -87,7 +89,7 @@ async fn main() { ui::scenario("outsider (master realm, an untrusted issuer) → get_compensation (rejected)"); let o = mediate( - &mgr, + &engine, &outsider, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -97,6 +99,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("One resolver, two trusted issuers: CPEX validates each token on the keys of the issuer its `iss` names, and rejects the rest."); + println!( + "One resolver, two trusted issuers: CPEX validates each token on the keys of the issuer its `iss` names, and rejects the rest." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m18_attributes.rs b/examples/tutorial/examples/m18_attributes.rs index 5814b4db..4107ed8e 100644 --- a/examples/tutorial/examples/m18_attributes.rs +++ b/examples/tutorial/examples/m18_attributes.rs @@ -18,11 +18,11 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; use cpex_tutorial::idp; use cpex_tutorial::ui; -use cpex_tutorial::{mediate, Caller}; +use cpex_tutorial::{Caller, mediate}; use serde_json::json; @@ -42,11 +42,13 @@ async fn token(user: &str) -> Caller { async fn main() { ui::module_banner("Module 18: Static attributes (operator facts in the data.* tree)"); - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(POLICY) + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + engine + .load_config_yaml(POLICY) .expect("policy m18.yaml should load (run from the repo root so attribute_files resolves)"); - mgr.initialize().await.expect("initialize"); + engine.initialize().await.expect("initialize"); let alice = token("alice").await; let mut all_passed = true; @@ -55,7 +57,7 @@ async fn main() { // file, so it's refused. Nothing about alice changed. ui::scenario("alice → get_compensation (data.controls says this tool is disabled)"); let o = mediate( - &mgr, + &engine, &alice, "get_compensation", json!({ "employee_id": "e-1001" }), @@ -68,7 +70,7 @@ async fn main() { // search_repos is switched ON in the same file, so the same caller passes. ui::scenario("alice → search_repos (data.controls says this tool is enabled)"); let o = mediate( - &mgr, + &engine, &alice, "search_repos", json!({ "visibility": "internal" }), @@ -78,6 +80,8 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - println!("Same caller, opposite outcomes, decided by an operator fact in a data file, read as data.*, not by anything in the request."); + println!( + "Same caller, opposite outcomes, decided by an operator fact in a data file, read as data.*, not by anything in the request." + ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/wait_for_idp.rs b/examples/tutorial/examples/wait_for_idp.rs index 4e5a36c3..286643ee 100644 --- a/examples/tutorial/examples/wait_for_idp.rs +++ b/examples/tutorial/examples/wait_for_idp.rs @@ -11,8 +11,8 @@ // exits 1 if it does not come up in time. This is the single place readiness // is gated: `make tutorial-check` runs it once after `docker compose up`, so // the IdP-backed modules do not each have to wait. The JWT plugin fetches -// JWKS at manager initialize(), so the realm must be serving before any -// module starts — running this probe first guarantees that. +// JWKS at engine initialize(), so the realm must be serving before any +// module starts; running this probe first guarantees that. use std::time::Duration; diff --git a/examples/tutorial/policies/capstone-nodeleg.yaml b/examples/tutorial/policies/capstone-nodeleg.yaml index 2c7da783..c493fa23 100644 --- a/examples/tutorial/policies/capstone-nodeleg.yaml +++ b/examples/tutorial/policies/capstone-nodeleg.yaml @@ -7,14 +7,14 @@ # cargo run -p cpex-tutorial --example capstone -- --no-delegation global: - apl: - pdp: - - kind: cel + pdp: + - kind: cel plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: @@ -28,11 +28,9 @@ plugins: leeway_seconds: 60 - name: audit-log - kind: audit/logger + kind: tutorial/audit-line hooks: [cmf.tool_pre_invoke] capabilities: [read_subject, read_meta] - config: - destination: stderr routes: - tool: get_compensation diff --git a/examples/tutorial/policies/capstone.yaml b/examples/tutorial/policies/capstone.yaml index 3ab20fb1..aa8c1587 100644 --- a/examples/tutorial/policies/capstone.yaml +++ b/examples/tutorial/policies/capstone.yaml @@ -16,14 +16,14 @@ # The application never changes. Only this policy decides the outcomes. global: - apl: - pdp: - - kind: cel + pdp: + - kind: cel plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: @@ -39,7 +39,7 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway @@ -49,11 +49,9 @@ plugins: insecure_http: true - name: audit-log - kind: audit/logger + kind: tutorial/audit-line hooks: [cmf.tool_pre_invoke] capabilities: [read_subject, read_meta] - config: - destination: stderr routes: # HR lookup: gate on role, mint a scoped downstream token, taint the diff --git a/examples/tutorial/policies/m01.yaml b/examples/tutorial/policies/m01.yaml index 577dd000..89bace94 100644 --- a/examples/tutorial/policies/m01.yaml +++ b/examples/tutorial/policies/m01.yaml @@ -7,16 +7,17 @@ # until module 2, so every caller here is anonymous, this route # always DENIES, and the denial names the predicate that failed. # -# * search_repos has no pre-invocation rule, so nothing blocks it, it -# always ALLOWS. A route with no denying rule is open by default. +# * search_repos has a single unconditional `allow`, so nothing blocks +# it, it always ALLOWS. An `authorization:` block must name at least +# one step, so an open route says so explicitly. # # The lesson: the route defines the pipeline. Same caller, same backend, # different route -> different outcome. Nothing in the Rust code changed. # # Try it: change `require(authenticated)` to `require(role.hr)` and re-run. -# get_compensation still denies, but the reason code changes. Or add a -# `require(authenticated)` line under search_repos and watch it start -# denying too. Reset with `git checkout -- examples/tutorial/policies`. +# get_compensation still denies, but the reason code changes. Or replace +# `allow` under search_repos with `require(authenticated)` and watch it +# start denying too. Reset with `git checkout -- examples/tutorial/policies`. routes: - tool: get_compensation @@ -26,4 +27,5 @@ routes: - tool: search_repos authorization: - pre_invocation: [] + pre_invocation: + - "allow" diff --git a/examples/tutorial/policies/m02.yaml b/examples/tutorial/policies/m02.yaml index 1c4e8ed9..cecda728 100644 --- a/examples/tutorial/policies/m02.yaml +++ b/examples/tutorial/policies/m02.yaml @@ -20,6 +20,9 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + # Fetching the JWKS is an outbound HTTP call, made through the transport + # the host installs. A plugin may use it only if it declares perform_http. + capabilities: [perform_http] config: role: user header: Authorization diff --git a/examples/tutorial/policies/m03.yaml b/examples/tutorial/policies/m03.yaml index 1dde1caa..4934c0cf 100644 --- a/examples/tutorial/policies/m03.yaml +++ b/examples/tutorial/policies/m03.yaml @@ -8,6 +8,9 @@ # * salary is redacted unless the caller has the hr role. # * employee_id is always masked to its last 4 characters. # +# The `allow` step is there because an `authorization:` block must name +# at least one step; the interesting part of this route is `result:`. +# # A pipeline entry reads `: " | ()"`. The op runs # only when its predicate is true; `redact(!perm.view_ssn)` means "redact # when the caller does NOT have view_ssn." @@ -20,7 +23,8 @@ routes: - tool: get_compensation authorization: - pre_invocation: [] + pre_invocation: + - "allow" result: ssn: "str | redact(!perm.view_ssn)" salary: "int | redact(!role.hr)" diff --git a/examples/tutorial/policies/m04.yaml b/examples/tutorial/policies/m04.yaml index 13d9fa3c..249d4a35 100644 --- a/examples/tutorial/policies/m04.yaml +++ b/examples/tutorial/policies/m04.yaml @@ -8,7 +8,9 @@ # # 1. run(audit-log): a side-effecting plugin. It records the attempt and # always allows, so the pipeline continues. Watch stderr for its line -# even when the call is later denied. +# even when the call is later denied. Its kind, tutorial/audit-line, +# is a small host plugin the tutorial harness registers (see +# src/audit.rs); CPEX ships no audit plugin of its own. # 2. a deny guard: `deny('msg','code')` produces a denial with a machine # code you choose, so downstream systems can branch on it. Here we # block outbound email whose recipient is external. @@ -21,11 +23,9 @@ plugins: - name: audit-log - kind: audit/logger + kind: tutorial/audit-line hooks: [cmf.tool_pre_invoke] capabilities: [read_subject, read_meta] - config: - destination: stderr routes: - tool: send_email diff --git a/examples/tutorial/policies/m05.yaml b/examples/tutorial/policies/m05.yaml index e15baa95..2627ae7a 100644 --- a/examples/tutorial/policies/m05.yaml +++ b/examples/tutorial/policies/m05.yaml @@ -12,19 +12,19 @@ # * evan (engineer) + public -> deny (engineers are internal-only here) # * sam (security) + anything -> allow # -# A PDP is declared once under global.apl.pdp, then referenced by steps. +# A PDP is declared once under global.pdp, then referenced by steps. # CEL expressions must guard optional attributes with has(...), because a # reference to an unset attribute is an error (and errors fail closed). global: - apl: - pdp: - - kind: cel + pdp: + - kind: cel plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: diff --git a/examples/tutorial/policies/m06.yaml b/examples/tutorial/policies/m06.yaml index 8fd03df4..aea9caaa 100644 --- a/examples/tutorial/policies/m06.yaml +++ b/examples/tutorial/policies/m06.yaml @@ -24,6 +24,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: @@ -43,7 +44,8 @@ plugins: # it) and write the minted downstream token. Declaring these here lets # the route surface them to the enforcement boundary; without them the # inbound token is filtered out before the exchange and it fails. - capabilities: [read_inbound_credentials, write_delegated_tokens] + # perform_http lets it call the token endpoint. + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway diff --git a/examples/tutorial/policies/m07.yaml b/examples/tutorial/policies/m07.yaml index c9d303fd..6094c3ee 100644 --- a/examples/tutorial/policies/m07.yaml +++ b/examples/tutorial/policies/m07.yaml @@ -27,6 +27,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: diff --git a/examples/tutorial/policies/m08.yaml b/examples/tutorial/policies/m08.yaml index 570b5b94..315d4026 100644 --- a/examples/tutorial/policies/m08.yaml +++ b/examples/tutorial/policies/m08.yaml @@ -25,6 +25,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: diff --git a/examples/tutorial/policies/m11.yaml b/examples/tutorial/policies/m11.yaml index 31638497..67fd0808 100644 --- a/examples/tutorial/policies/m11.yaml +++ b/examples/tutorial/policies/m11.yaml @@ -19,6 +19,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: user header: Authorization diff --git a/examples/tutorial/policies/m12.yaml b/examples/tutorial/policies/m12.yaml index 21cf00f1..df2f30e1 100644 --- a/examples/tutorial/policies/m12.yaml +++ b/examples/tutorial/policies/m12.yaml @@ -23,6 +23,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: claim_mapper: standard trusted_issuers: @@ -38,7 +39,7 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway diff --git a/examples/tutorial/policies/m13.yaml b/examples/tutorial/policies/m13.yaml index d4cad8ee..3caf8a6e 100644 --- a/examples/tutorial/policies/m13.yaml +++ b/examples/tutorial/policies/m13.yaml @@ -26,6 +26,7 @@ plugins: - name: keycloak-agent kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: client header: Authorization @@ -43,7 +44,7 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway diff --git a/examples/tutorial/policies/m14.yaml b/examples/tutorial/policies/m14.yaml index 5e2e4702..93c49454 100644 --- a/examples/tutorial/policies/m14.yaml +++ b/examples/tutorial/policies/m14.yaml @@ -19,6 +19,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: user header: Authorization diff --git a/examples/tutorial/policies/m15.yaml b/examples/tutorial/policies/m15.yaml index 83de1ca6..4582f072 100644 --- a/examples/tutorial/policies/m15.yaml +++ b/examples/tutorial/policies/m15.yaml @@ -24,6 +24,7 @@ plugins: - name: jwt-user kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: user header: X-User-Token @@ -42,6 +43,7 @@ plugins: - name: jwt-agent kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: client header: Authorization @@ -59,7 +61,7 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway diff --git a/examples/tutorial/policies/m16.yaml b/examples/tutorial/policies/m16.yaml index a1a03927..d5daa10c 100644 --- a/examples/tutorial/policies/m16.yaml +++ b/examples/tutorial/policies/m16.yaml @@ -28,6 +28,7 @@ plugins: - name: jwt-workload kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: caller_workload header: X-Workload-Token @@ -45,7 +46,7 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - capabilities: [read_inbound_credentials, write_delegated_tokens] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] config: token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token client_id: cpex-gateway diff --git a/examples/tutorial/policies/m17.yaml b/examples/tutorial/policies/m17.yaml index a91feead..679c3999 100644 --- a/examples/tutorial/policies/m17.yaml +++ b/examples/tutorial/policies/m17.yaml @@ -15,6 +15,7 @@ plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: user header: Authorization diff --git a/examples/tutorial/policies/m18.yaml b/examples/tutorial/policies/m18.yaml index a9fccd82..eed6d208 100644 --- a/examples/tutorial/policies/m18.yaml +++ b/examples/tutorial/policies/m18.yaml @@ -13,16 +13,16 @@ # fact in a data file, not anything about the request. global: - apl: - # Deep-merged, in order, into one `data.*` tree at startup. Paths are - # relative to where you run the example (the repo root). - attribute_files: - - examples/tutorial/policies/attributes/controls.yaml + # Deep-merged, in order, into one `data.*` tree at startup. Paths are + # relative to where you run the example (the repo root). + attribute_files: + - examples/tutorial/policies/attributes/controls.yaml plugins: - name: keycloak kind: identity/jwt hooks: [identity.resolve] + capabilities: [perform_http] config: role: user header: Authorization diff --git a/examples/tutorial/src/audit.rs b/examples/tutorial/src/audit.rs new file mode 100644 index 00000000..454447bd --- /dev/null +++ b/examples/tutorial/src/audit.rs @@ -0,0 +1,120 @@ +// Location: ./examples/tutorial/src/audit.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// A one-line audit plugin for the tutorial. +// +// Module 4 and the capstone need a side effect that runs before a denial so +// you can watch halt-on-deny keep it. CPEX ships no audit plugin of its own, +// since the record's format and destination are what a deployment replaces, +// so the harness carries this small one. It writes one JSON line per call to +// stderr and always allows. +// +// It is an ordinary host plugin, built exactly the way module 9 teaches: +// a `Plugin`, a `HookHandler`, and a `PluginFactory` registered +// under a `kind` before the policy loads. Policy reaches it by name with +// `run(audit-log)`, like any other plugin. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use serde_json::{Map, Value, json}; + +use cpex::PolicyEngine; +use cpex::prelude::{ + CmfHook, ContentPart, Extensions, HookHandler, MessagePayload, Plugin, PluginConfig, + PluginContext, PluginError, PluginFactory, PluginInstance, PluginResult, TypedHandlerAdapter, +}; + +/// The `kind` a policy declares to use this plugin. +pub const KIND: &str = "tutorial/audit-line"; + +/// Register the audit plugin's factory on `engine`. Call it before loading a +/// policy that declares `kind: tutorial/audit-line`. +pub fn register(engine: &Arc) { + engine.register_factory(KIND, Box::new(AuditLineFactory)); +} + +struct AuditLine { + cfg: PluginConfig, +} + +impl AuditLine { + /// Build the record from what the plugin's capabilities let it see: + /// the entity (`read_meta`), the subject (`read_subject`), and the call's + /// tool name and argument keys. + fn record(&self, payload: &MessagePayload, ext: &Extensions) -> Value { + let mut record = Map::new(); + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + record.insert("ts".into(), json!(ts)); + record.insert("plugin".into(), json!(self.cfg.name)); + if let Some(meta) = &ext.meta { + record.insert( + "entity".into(), + json!({ "type": meta.entity_type, "name": meta.entity_name }), + ); + } + if let Some(subject) = ext.security.as_ref().and_then(|s| s.subject.as_ref()) { + let mut roles: Vec<&String> = subject.roles.iter().collect(); + roles.sort(); + record.insert( + "subject".into(), + json!({ "id": subject.id, "roles": roles }), + ); + } + for part in &payload.message.content { + if let ContentPart::ToolCall { content } = part { + // Argument values are left out on purpose: they can carry PII or secrets. + let mut arg_keys: Vec<&String> = content.arguments.keys().collect(); + arg_keys.sort(); + record.insert( + "tool_call".into(), + json!({ "name": content.name, "arg_keys": arg_keys }), + ); + break; + } + } + Value::Object(record) + } +} + +#[async_trait] +impl Plugin for AuditLine { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLine { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + eprintln!("{}", self.record(payload, extensions)); + PluginResult::allow() + } +} + +struct AuditLineFactory; + +impl PluginFactory for AuditLineFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLine { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} diff --git a/examples/tutorial/src/backends.rs b/examples/tutorial/src/backends.rs index d547cf37..92b69c72 100644 --- a/examples/tutorial/src/backends.rs +++ b/examples/tutorial/src/backends.rs @@ -11,7 +11,7 @@ // That separation is the whole point, the same backend returns the // same bytes regardless of caller; policy decides who sees what. -use serde_json::{json, Value}; +use serde_json::{Value, json}; /// Look up an employee's full HR record by id. The record includes an /// SSN and salary; policy (not this function) decides whether the diff --git a/examples/tutorial/src/idp.rs b/examples/tutorial/src/idp.rs index e6d6d44a..5abbefdf 100644 --- a/examples/tutorial/src/idp.rs +++ b/examples/tutorial/src/idp.rs @@ -200,10 +200,10 @@ pub async fn wait_until_ready(max_wait: Duration) -> Result<(), String> { let url = format!("{}/.well-known/openid-configuration", issuer()); let deadline = tokio::time::Instant::now() + max_wait; loop { - if let Ok(resp) = client.get(&url).send().await { - if resp.status().is_success() { - return Ok(()); - } + if let Ok(resp) = client.get(&url).send().await + && resp.status().is_success() + { + return Ok(()); } if tokio::time::Instant::now() >= deadline { return Err(format!( diff --git a/examples/tutorial/src/lib.rs b/examples/tutorial/src/lib.rs index adf09520..d8ac5ac2 100644 --- a/examples/tutorial/src/lib.rs +++ b/examples/tutorial/src/lib.rs @@ -15,15 +15,45 @@ // policy) behind a single call. See its "no magic" // note: this is harness code, not a CPEX API. // * `idp` , mint a token from the tutorial Keycloak realm. +// * `audit` , a one-line audit plugin for the modules that show a +// side effect running before a denial (4, capstone). // * `approvals`, a tiny HTTP approval channel for the elicitation // module, driven from a second terminal with `curl`. // * `ui` , banner / outcome printing shared by every module so // the terminal recordings look consistent. +// +// It also provides `install_http_transport`, the one engine setting every +// module shares: the HTTP transport the identity, delegation and approval +// builtins use to reach the tutorial IdP. pub mod approvals; +pub mod audit; pub mod backends; pub mod idp; pub mod mediate; pub mod ui; -pub use mediate::{mediate, Caller, Outcome}; +pub use mediate::{Caller, Outcome, mediate}; + +use std::sync::Arc; + +use cpex::{HyperTransport, PolicyEngine}; + +/// Install the HTTP transport the tutorial's builtins use. +/// +/// Builtins that fetch a JWKS, exchange a token, or open a CIBA request do +/// not bring their own HTTP client: the host installs one on the engine, and +/// each such plugin declares the `perform_http` capability to use it. The +/// bundled transport refuses private and loopback destinations, and the +/// tutorial IdP runs on `localhost`, so this one allows them. +/// +/// NOT A PRODUCTION SETTING. Allowing private destinations lets a policy +/// document point a plugin at anything on the host's network. A production +/// host keeps the default (`cpex::install_default_http_transport`) or installs +/// its own transport with its own egress rules. +pub fn install_http_transport(engine: &Arc) { + cpex::host::warn_private_destinations(); + engine.set_http_transport(Arc::new( + HyperTransport::new().with_allow_private_destinations(), + )); +} diff --git a/examples/tutorial/src/mediate.rs b/examples/tutorial/src/mediate.rs index 3db9ee1d..2594fed7 100644 --- a/examples/tutorial/src/mediate.rs +++ b/examples/tutorial/src/mediate.rs @@ -37,13 +37,15 @@ use std::sync::Arc; use serde_json::{Map, Value}; -use cpex::PluginManager; -use cpex_core::cmf::content::{ToolCall, ToolResult}; -use cpex_core::cmf::enums::Role; -use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; -use cpex_core::executor::PipelineResult; -use cpex_core::extensions::{AgentExtension, Extensions, HttpExtension, MetaExtension}; -use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex::PolicyEngine; +use cpex::prelude::{ + CmfHook, ContentPart, Extensions, Message, MessagePayload, Role, ToolCall, ToolResult, +}; +use praxis_policy_core::executor::PipelineResult; +use praxis_policy_core::extensions::{AgentExtension, HttpExtension, MetaExtension}; +use praxis_policy_core::identity::{ + HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource, +}; /// Request header an agent echoes to resume a suspended elicitation. Its /// value is the `elicitation_id` from a prior pending outcome. @@ -149,7 +151,7 @@ impl Outcome { /// This mirrors a real host, where CPEX sits in front of the actual tool /// and never calls it directly. pub async fn mediate( - mgr: &Arc, + engine: &Arc, caller: &Caller, tool: &str, args: Value, @@ -205,7 +207,7 @@ where IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(headers) }, }; - let (id_result, id_bg) = mgr + let (id_result, id_bg) = engine .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, ext.clone(), None) .await; id_bg.wait_for_background_tasks().await; @@ -221,32 +223,32 @@ where let pre_payload = MessagePayload { message: tool_call_message(tool, &args), }; - let (pre_result, pre_bg) = mgr + let (pre_result, pre_bg) = engine .invoke_named::("cmf.tool_pre_invoke", pre_payload, ext.clone(), None) .await; pre_bg.wait_for_background_tasks().await; if !pre_result.continue_processing { // A suspended elicitation surfaces as a distinguished violation // carrying the elicitation id, not an ordinary denial. - if let Some(v) = &pre_result.violation { - if v.code == "elicitation.pending" { - let id = v - .details - .get("elicitation_id") - .and_then(|x| x.as_str()) - .unwrap_or_default() - .to_string(); - let approver = v - .details - .get("approver") - .and_then(|x| x.as_str()) - .unwrap_or("the approver") - .to_string(); - return Outcome::Pending { - elicitation_id: id, - approver, - }; - } + if let Some(v) = &pre_result.violation + && v.code == "elicitation.pending" + { + let id = v + .details + .get("elicitation_id") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let approver = v + .details + .get("approver") + .and_then(|x| x.as_str()) + .unwrap_or("the approver") + .to_string(); + return Outcome::Pending { + elicitation_id: id, + approver, + }; } return denied(&pre_result); } @@ -264,7 +266,7 @@ where let post_payload = MessagePayload { message: tool_result_message(tool, &raw_result), }; - let (post_result, post_bg) = mgr + let (post_result, post_bg) = engine .invoke_named::("cmf.tool_post_invoke", post_payload, post_ext, None) .await; post_bg.wait_for_background_tasks().await; diff --git a/examples/tutorial/tests/policy_tests.rs b/examples/tutorial/tests/policy_tests.rs index 8e372ed4..a9e6efb5 100644 --- a/examples/tutorial/tests/policy_tests.rs +++ b/examples/tutorial/tests/policy_tests.rs @@ -8,7 +8,7 @@ // cargo test -p cpex-tutorial // // These tests show how to unit-test APL without a live IdP or backend. -// You load a policy into a manager, drive routes through `mediate()` with +// You load a policy into an engine, drive routes through `mediate()` with // a fake backend, and assert on the outcome. Table-driven cases keep the // allow/deny matrix readable and make a new case one line. // @@ -19,19 +19,21 @@ use std::sync::Arc; -use cpex::PluginManager; +use cpex::PolicyEngine; use cpex_tutorial::backends; -use cpex_tutorial::{mediate, Caller, Outcome}; +use cpex_tutorial::{Caller, Outcome, mediate}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; -/// Load a policy string into a ready-to-use manager. -async fn manager_with(policy: &str) -> Arc { - let mgr = Arc::new(PluginManager::default()); - cpex::install_builtins(&mgr); - mgr.load_config_yaml(policy).expect("policy should load"); - mgr.initialize().await.expect("initialize"); - mgr +/// Load a policy string into a ready-to-use engine. +async fn engine_with(policy: &str) -> Arc { + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + cpex_tutorial::audit::register(&engine); + engine.load_config_yaml(policy).expect("policy should load"); + engine.initialize().await.expect("initialize"); + engine } /// A single table row: call `tool` with `args` and expect allow or deny. @@ -42,8 +44,8 @@ struct Case { want_code: Option<&'static str>, } -async fn run_case(mgr: &Arc, c: &Case) { - let outcome = mediate(mgr, &Caller::anonymous(), c.tool, c.args.clone(), |a| { +async fn run_case(engine: &Arc, c: &Case) { + let outcome = mediate(engine, &Caller::anonymous(), c.tool, c.args.clone(), |a| { backends::dispatch(c.tool, a) }) .await; @@ -66,7 +68,7 @@ const M04: &str = include_str!("../policies/m04.yaml"); #[tokio::test] async fn module1_gates_by_authentication() { - let mgr = manager_with(M01).await; + let engine = engine_with(M01).await; let cases = [ Case { tool: "get_compensation", @@ -82,13 +84,13 @@ async fn module1_gates_by_authentication() { }, ]; for c in &cases { - run_case(&mgr, c).await; + run_case(&engine, c).await; } } #[tokio::test] async fn module4_external_email_denied_with_custom_code() { - let mgr = manager_with(M04).await; + let engine = engine_with(M04).await; let cases = [ Case { tool: "send_email", @@ -104,6 +106,136 @@ async fn module4_external_email_denied_with_custom_code() { }, ]; for c in &cases { - run_case(&mgr, c).await; + run_case(&engine, c).await; } } + +// --------------------------------------------------------------------------- +// Every module's policy loads. +// +// Loading is where the engine checks a policy's shape: unknown or removed +// keys, plugins no step reaches, steps naming plugins nobody declared, and +// capabilities a plugin needs but does not declare. The modules above only +// load two policies, and most module binaries need the IdP to run, so this +// test loads every policy offline. It does not call `initialize()`, which is +// where the identity plugins fetch their JWKS from the IdP. +// --------------------------------------------------------------------------- + +use cpex::prelude::{ + CmfHook, Extensions, HookHandler, MessagePayload, Plugin, PluginConfig, PluginContext, + PluginError, PluginFactory, PluginInstance, PluginResult, TypedHandlerAdapter, +}; +use praxis_policy_core::elicitation::{ElicitationHook, ElicitationPayload}; + +/// Stand-in for the plugins modules 8 and 9 define in their own binaries. +/// Loading only needs a factory for the `kind` and a handler on the right +/// hook; this one never runs. +struct Stub { + cfg: PluginConfig, +} + +#[async_trait::async_trait] +impl Plugin for Stub { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for Stub { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +impl HookHandler for Stub { + async fn handle( + &self, + _payload: &ElicitationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct StubFactory { + hook: &'static str, +} + +impl PluginFactory for StubFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(Stub { + cfg: config.clone(), + }); + let handler: Arc = if self.hook == "elicit" { + Arc::new(TypedHandlerAdapter::::new( + plugin.clone(), + )) + } else { + Arc::new(TypedHandlerAdapter::::new(plugin.clone())) + }; + Ok(PluginInstance { + plugin, + handlers: vec![(self.hook, handler)], + }) + } +} + +#[test] +fn every_module_policy_loads() { + let policies = [ + ("m01", include_str!("../policies/m01.yaml")), + ("m02", include_str!("../policies/m02.yaml")), + ("m03", include_str!("../policies/m03.yaml")), + ("m04", include_str!("../policies/m04.yaml")), + ("m05", include_str!("../policies/m05.yaml")), + ("m06", include_str!("../policies/m06.yaml")), + ("m07", include_str!("../policies/m07.yaml")), + ("m08", include_str!("../policies/m08.yaml")), + ("m09", include_str!("../policies/m09.yaml")), + ("m11", include_str!("../policies/m11.yaml")), + ("m12", include_str!("../policies/m12.yaml")), + ("m13", include_str!("../policies/m13.yaml")), + ("m14", include_str!("../policies/m14.yaml")), + ("m15", include_str!("../policies/m15.yaml")), + ("m16", include_str!("../policies/m16.yaml")), + ("m17", include_str!("../policies/m17.yaml")), + ("m18", include_str!("../policies/m18.yaml")), + ("capstone", include_str!("../policies/capstone.yaml")), + ( + "capstone-nodeleg", + include_str!("../policies/capstone-nodeleg.yaml"), + ), + ]; + // Module 18's attribute file path is relative to the repo root, where the + // module binaries run; tests run from the crate directory. + let attributes_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/policies/attributes/"); + let mut failures = Vec::new(); + for (name, yaml) in policies { + let yaml = yaml.replace("examples/tutorial/policies/attributes/", attributes_dir); + let engine = Arc::new(PolicyEngine::default()); + engine.register_factory( + "business-hours", + Box::new(StubFactory { + hook: "cmf.tool_pre_invoke", + }), + ); + engine.register_factory("approval-channel", Box::new(StubFactory { hook: "elicit" })); + cpex::install_builtins(&engine); + cpex_tutorial::install_http_transport(&engine); + cpex_tutorial::audit::register(&engine); + if let Err(e) = engine.load_config_yaml(&yaml) { + failures.push(format!("{name}: {e}")); + } + } + assert!( + failures.is_empty(), + "policies failed to load:\n{}", + failures.join("\n") + ); +} diff --git a/go/cpex/README.md b/go/cpex/README.md index 20486240..0383963d 100644 --- a/go/cpex/README.md +++ b/go/cpex/README.md @@ -1,11 +1,11 @@ # CPEX Go SDK -Go bindings for the CPEX plugin runtime. Wraps the Rust core via cgo — all plugin execution happens in Rust, called from Go through MessagePack-serialized payloads and opaque handles. +Go bindings for the CPEX policy engine. Wraps the Rust engine via cgo: all plugin execution happens in Rust, called from Go through MessagePack-serialized payloads and opaque handles. ## Prerequisites -- **Go 1.21+** -- **Rust toolchain** (stable, 1.75+) +- **Go 1.25+** +- **Rust toolchain** (1.96+, pinned in `rust-toolchain.toml`) - **Built Rust library**: the Go SDK links against `libcpex_ffi.a` ```bash @@ -17,11 +17,15 @@ cargo build --release -p cpex-ffi ``` go/cpex/ - ffi.go — cgo declarations (C function signatures) - manager.go — PluginManager, ContextTable, BackgroundTasks - types.go — Extensions, PipelineResult, payload constants - cmf.go — CMF Message, ContentPart, domain objects - manager_test.go — tests (require built libcpex_ffi) + ffi.go cgo declarations (C function signatures) and link flags + abi.go FFI ABI version check at package init + engine.go PolicyEngine, ContextTable, BackgroundTasks + apl.go EnableAPL: builtins, HTTP transport, secret providers + secrets.go RefreshSecrets, SecretsLastSuccess + types.go Extensions, PipelineResult, payload constants + cmf.go CMF Message, ContentPart, domain objects + identity.go IdentityPayload for identity.resolve + *_test.go tests (require built libcpex_ffi) ``` ## Quick Start @@ -29,27 +33,35 @@ go/cpex/ ```go import cpex "github.com/contextforge-org/cpex/go/cpex" -// 1. Create a manager -mgr, err := cpex.NewPluginManagerDefault() -defer mgr.Shutdown() +// 1. Create an engine +engine, err := cpex.NewPolicyEngineDefault() +if err != nil { ... } +defer engine.Shutdown() -// 2. Register plugin factories (Rust-side, via callback) -mgr.RegisterFactories(func(handle unsafe.Pointer) error { +// 2. Install the CPEX builtins (identity/jwt, delegator/oauth, PDPs, Vault, ...). +// Private and loopback destinations are blocked unless you opt in. +engine.EnableAPL(cpex.WithPrivateDestinations()) + +// 3. Optionally register your own Rust plugin factories (via callback) +engine.RegisterFactories(func(handle unsafe.Pointer) error { C.my_register_factories(handle) return nil }) -// 3. Load YAML config -mgr.LoadConfig(yamlString) +// 4. Load YAML config +engine.LoadConfig(yamlString) -// 4. Initialize plugins -mgr.Initialize() +// 5. Initialize plugins +engine.Initialize() -// 5. Invoke a hook -result, ct, bg, err := mgr.InvokeByName( - "tool_pre_invoke", - cpex.PayloadGeneric, - map[string]any{"tool_name": "get_compensation", "user": "alice"}, +// 6. Invoke a hook +payload := cpex.MessagePayload{Message: cpex.NewMessage("assistant", + cpex.NewToolCallPart(cpex.ToolCall{ToolCallID: "tc-1", Name: "get_compensation"}), +)} +result, ct, bg, err := engine.InvokeByName( + "cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, + payload, &cpex.Extensions{ Meta: &cpex.MetaExtension{ EntityType: "tool", @@ -67,25 +79,62 @@ if result.IsDenied() { } ``` +Hook names follow the engine: `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`, `cmf.prompt_pre_invoke`, `cmf.prompt_post_invoke`, `cmf.resource_pre_fetch`, `cmf.resource_post_fetch`, `identity.resolve`, `token.delegate`, and so on. The old unprefixed names (`tool_pre_invoke`, `identity_resolve`, ...) are rejected at config load, and `InvokeByName` and `InvokeResolved` refuse them with `ErrCpexInvalidInput`: such a name reaches no handler, so dispatching it would come back allowed with no policy evaluated. + ## Lifecycle ``` -NewPluginManagerDefault() - → RegisterFactories(fn) // register Rust plugin factories +NewPolicyEngineDefault() + → EnableAPL(opts...) // optional: builtins, transport, secret providers + → RegisterFactories(fn) // optional: your own Rust plugin factories → LoadConfig(yaml) // parse YAML, instantiate plugins - → Initialize() // call plugin.initialize() on all + → Initialize() // call plugin.initialize() on all, resolve secrets → InvokeByName(...) // invoke hooks, get results + → RefreshSecrets() // optional, on a schedule: re-read secrets → Shutdown() // call plugin.shutdown(), free resources ``` +`NewPolicyEngine(yaml)` creates an engine and loads the config in one step. It registers no builtins, so it suits configs that only name plugins registered in Rust. + +## Builtins and Egress + +`EnableAPL` installs every bundled builtin, one outbound HTTP transport, and the `env`, `file` and `vault` secret providers. Plugins that reach the network (`jwks_url`, OAuth delegation, CIBA, Vault) must declare `capabilities: [perform_http]`. + +The transport refuses private, loopback and link-local destinations by default. Pass `cpex.WithPrivateDestinations()` to allow them, for example for a local IdP. This is a host decision; no policy document can turn it on. `EnableAPL` is idempotent after a success: a second call does nothing, whatever its options. A call that panicked stays failed: it and every later call return an error wrapping `ErrCpexPanic`, because the engine may be half installed. Discard the engine and create a new one. + +`WithPrivateDestinations` is engine-wide. It lets every outbound builtin (JWKS, token exchange, CIBA, Vault) reach loopback, RFC 1918, link-local addresses including cloud metadata (`169.254.169.254`), and CGNAT. Use it for local development and demos only. A blocked JWKS fetch shows up as `auth.jwks_unavailable`; a blocked token exchange as `delegation.egress_denied`. + +`LoadConfig` returns a typed error without the engine's message. The message, naming the rejected key, hook or plugin, goes to the FFI's `tracing` log. + +## Secrets + +The engine resolves secrets once, at `Initialize`, and never re-reads them on its own: + +```go +report, err := engine.RefreshSecrets() // Updated, Unchanged, Failed +at, ok, err := engine.SecretsLastSuccess("vault") +if !ok { + // the provider has never refreshed successfully, or is undeclared +} +``` + +A value that fails to re-read keeps its last-good bytes and is listed in `report.Failed`. A host that uses Vault, or rotates any secret, must call `RefreshSecrets` on an interval (for example from a `time.Ticker`) and alarm when `SecretsLastSuccess` for a provider falls too far behind. Nothing else re-reads secrets, so the interval bounds how long a revoked credential stays in use. + +## 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. + ## Payload Types | Constant | Value | Description | |----------------------|-------|----------------------------------| | `PayloadGeneric` | 0 | Generic map payload (`map[string]any`) | | `PayloadCMFMessage` | 1 | Typed CMF `MessagePayload` | +| `PayloadIdentity` | 2 | `IdentityPayload` for `identity.resolve` | -Use `PayloadGeneric` for simple key-value payloads. Use `PayloadCMFMessage` when sending structured CMF messages with typed content parts (tool calls, resources, media, etc.). +Use `PayloadGeneric` for simple key-value payloads. Use `PayloadCMFMessage` when sending structured CMF messages with typed content parts (tool calls, resources, media, etc.). Use `PayloadIdentity` with `identity.resolve` (build it with `NewIdentityPayload`), and pass the route's `Meta` in the extensions: under the default `dispatch: policy`, the engine needs the entity to know which route's `authentication:` to run. An identity input may set only `Source`, `SourceHeader`, `Headers`, `ClientHost` and `ClientPort`; one that pre-fills a resolver output field (`Subject`, `RawClaims` and the like) is refused with `ErrCpexInvalidInput`. The resolved payload does not echo `Headers`, since they carry the inbound token. + +The generic HTTP hooks (`http.request`, `http.response`) are not invocable from Go in this release: the FFI has no payload type for the HTTP hook family. `HttpExtension` does carry the request line (`Method`, `Path`, `Host`, `Scheme`) and the response `Status`, so set `Method` and `Path` on the extensions of an `identity.resolve` call to select an `http:` route's own `authentication:` list. ## CMF Content Types @@ -108,30 +157,37 @@ The `ContentPart` tagged union supports all 12 content types: ## Extensions -Extensions are passed separately from the payload. Each extension type maps to a Rust extension in `crates/cpex-core/src/extensions/`: +Extensions are passed separately from the payload. Each extension type maps to a Rust extension in `praxis_policy_core::extensions`: + +- `MetaExtension`: entity identification for route resolution +- `SecurityExtension`: labels, classification, data policies, and four identity slots: `Subject` (user), `Client` (OAuth client), `CallerWorkload` and `ThisWorkload` (`WorkloadIdentity`) +- `HttpExtension`: request line, request/response headers, response status +- `DelegationExtension`: token delegation chain +- `AgentExtension`: agent session and conversation context +- `RequestExtension`: environment, tracing, request ID +- `MCPExtension`: MCP tool/resource/prompt metadata +- `CompletionExtension`: LLM completion stats +- `ProvenanceExtension`: message origin and threading +- `LLMExtension`: model identity and capabilities +- `FrameworkExtension`: agentic framework context +- `CandidateConstraintExtension`: model, region and site constraints a plugin sets for a router + +`SubjectExtension.Claims` and `ClientExtension.Claims` are `map[string]any`: a claim keeps its JSON shape (string, bool, number, array or nested map), so use a type switch or assertion to read one. -- `MetaExtension` — entity identification for route resolution -- `SecurityExtension` — labels, classification, subject identity -- `HttpExtension` — request/response headers -- `DelegationExtension` — token delegation chain -- `AgentExtension` — agent session and conversation context -- `RequestExtension` — environment, tracing, request ID -- `MCPExtension` — MCP tool/resource/prompt metadata -- `CompletionExtension` — LLM completion stats -- `ProvenanceExtension` — message origin and threading -- `LLMExtension` — model identity and capabilities -- `FrameworkExtension` — agentic framework context +Some `[]string` fields tell nil apart from empty, matching a Rust `Option>`: `DataPolicy.AllowedActions` and `CandidateConstraintExtension.AllowModels`, `AllowRegions` and `AllowSites`. A nil slice means unconstrained; an empty non-nil slice allows nothing. + +There is no Go field for the Rust `raw_credentials` slot. Its tokens are never serialized, so use `InvokeResolved` to keep inbound credentials inside the engine. ## Context Threading Pass the `ContextTable` from one invocation to the next to preserve per-plugin state across hooks: ```go -result1, ct1, bg1, _ := mgr.InvokeByName("tool_pre_invoke", ...) +result1, ct1, bg1, _ := engine.InvokeByName("cmf.tool_pre_invoke", ...) bg1.Close() // Thread context table into post-invoke -result2, ct2, bg2, _ := mgr.InvokeByName("tool_post_invoke", ..., ct1) +result2, ct2, bg2, _ := engine.InvokeByName("cmf.tool_post_invoke", ..., ct1) bg2.Close() ct2.Close() ``` @@ -163,7 +219,7 @@ edition = "2021" crate-type = ["staticlib", "cdylib"] [dependencies] -cpex-core = { path = "path/to/crates/cpex-core" } +praxis-policy-core = "=0.5.0" cpex-ffi = { path = "path/to/crates/cpex-ffi" } async-trait = "0.1" tracing = "0.1" @@ -176,16 +232,19 @@ tracing = "0.1" ```rust use std::sync::Arc; use async_trait::async_trait; -use cpex_core::context::PluginContext; -use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::factory::{PluginFactory, PluginInstance}; -use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::Extensions; -use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; -use cpex_core::plugin::{Plugin, PluginConfig}; +use praxis_policy_core::context::PluginContext; +use praxis_policy_core::error::{PluginError, PluginViolation}; +use praxis_policy_core::factory::{PluginFactory, PluginInstance}; +use praxis_policy_core::hooks::adapter::TypedHandlerAdapter; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::hooks::{register_hook_metadata, HookMetadata, HookPhase}; +use praxis_policy_core::plugin::{Plugin, PluginConfig}; use cpex_ffi::GenericPayload; -// Hook type — the NAME can be any string; Go callers use this name +// Hook type. NAME identifies the hook family; the hook name Go callers +// invoke is HOOK_MY, registered below. pub struct MyHook; impl HookTypeDef for MyHook { type Payload = GenericPayload; @@ -193,6 +252,8 @@ impl HookTypeDef for MyHook { const NAME: &'static str = "my_hook"; } +pub const HOOK_MY: &str = "my.hook"; + // Plugin implementation struct RateLimiter { cfg: PluginConfig, @@ -204,7 +265,7 @@ impl Plugin for RateLimiter { } impl HookHandler for RateLimiter { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, @@ -215,15 +276,15 @@ impl HookHandler for RateLimiter { } } -// Factory — creates plugin instances from YAML config +// Factory: creates plugin instances from YAML config pub struct RateLimiterFactory; impl PluginFactory for RateLimiterFactory { - fn create(&self, config: &PluginConfig) -> Result { + fn create(&self, config: &PluginConfig) -> Result> { let plugin = Arc::new(RateLimiter { cfg: config.clone() }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("my_hook", Arc::new( + (HOOK_MY, Arc::new( TypedHandlerAdapter::::new(plugin), )), ], @@ -231,8 +292,14 @@ impl PluginFactory for RateLimiterFactory { } } -pub fn register_factories(manager: &mut cpex_core::manager::PluginManager) { - manager.register_factory("my/rate-limiter", Box::new(RateLimiterFactory)); +pub fn register_factories(engine: &PolicyEngine) { + // The config loader refuses a hook name it has no metadata for, so + // describe the host hook before any config naming it loads. + register_hook_metadata( + HOOK_MY, + HookMetadata { family: Some(MyHook::NAME), entity_type: None, phase: HookPhase::Unphased }, + ); + engine.register_factory("my/rate-limiter", Box::new(RateLimiterFactory)); } ``` @@ -250,13 +317,13 @@ use std::os::raw::c_int; #[no_mangle] pub unsafe extern "C" fn my_register_factories( - mgr: *mut cpex_ffi::CpexManagerInner, + handle: *mut cpex_ffi::CpexEngineInner, ) -> c_int { - let inner = match mgr.as_mut() { - Some(m) => m, + let inner = match handle.as_ref() { + Some(h) => h, None => return -1, }; - my_plugin::register_factories(&mut inner.manager); + my_plugin::register_factories(&inner.engine); 0 } ``` @@ -266,31 +333,32 @@ pub unsafe extern "C" fn my_register_factories( ```go /* #cgo LDFLAGS: -L/path/to/target/release -lmy_plugins_ffi -lm -ldl -lpthread -int my_register_factories(void* mgr); +int my_register_factories(void* engine); */ import "C" -mgr, _ := cpex.NewPluginManagerDefault() +engine, _ := cpex.NewPolicyEngineDefault() -mgr.RegisterFactories(func(handle unsafe.Pointer) error { +engine.RegisterFactories(func(handle unsafe.Pointer) error { if C.my_register_factories(handle) != 0 { return fmt.Errorf("factory registration failed") } return nil }) -mgr.LoadConfig(yaml) // YAML references kind: "my/rate-limiter" -mgr.Initialize() +engine.LoadConfig(yaml) // YAML references kind: "my/rate-limiter" +engine.Initialize() -result, ct, bg, _ := mgr.InvokeByName("my_hook", cpex.PayloadGeneric, payload, ext, nil) +result, ct, bg, _ := engine.InvokeByName("my.hook", cpex.PayloadGeneric, payload, ext, nil) ``` ### Key points -- `extern crate cpex_ffi;` in your `lib.rs` ensures all core FFI symbols are included in your staticlib — Go links only your library -- The `CpexManagerInner` type from `cpex_ffi` gives you access to the `manager` field for factory registration -- Your C function signature is `int my_register_factories(void* mgr)` — Go passes the SDK's internal handle via the `RegisterFactories` callback +- `extern crate cpex_ffi;` in your `lib.rs` ensures all core FFI symbols are included in your staticlib, so Go links only your library +- The `CpexEngineInner` type from `cpex_ffi` gives you access to the `engine` field (an `Arc`) for factory registration +- Your C function signature is `int my_register_factories(void* engine)`. Go passes the SDK's internal handle via the `RegisterFactories` callback - The YAML `kind` field must match what you pass to `register_factory()` +- A config that declares `hooks:` and `priority:` on its plugins needs `engine_settings: {dispatch: hooks}`. Under the default `dispatch: policy`, call `EnableAPL` and name the plugin from a policy step such as `run(my-plugin)` instead ## Adding a New Payload Type @@ -298,7 +366,7 @@ The payload type registry maps a `uint8` discriminator to a Rust type for FFI de ### Rust side (3 files) -**1. Define the type** (in `cpex-core` or your own crate): +**1. Define the type** (in your own crate): ```rust #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -306,13 +374,13 @@ pub struct MyPayload { pub field_a: String, pub field_b: i64, } -cpex_core::impl_plugin_payload!(MyPayload); +praxis_policy_core::impl_plugin_payload!(MyPayload); ``` **2. Register in FFI** (`crates/cpex-ffi/src/lib.rs`): ```rust -pub const PAYLOAD_MY_TYPE: u8 = 2; +pub const PAYLOAD_MY_TYPE: u8 = 3; // In deserialize_payload(): PAYLOAD_MY_TYPE => { @@ -331,7 +399,7 @@ if let Some(mp) = payload.as_any().downcast_ref::() { **3. Define the Go struct** (`go/cpex/types.go` or a new file): ```go -const PayloadMyType uint8 = 2 +const PayloadMyType uint8 = 3 type MyPayload struct { FieldA string `msgpack:"field_a"` @@ -342,13 +410,13 @@ type MyPayload struct { ### Use it ```go -result, ct, bg, _ := mgr.InvokeByName("my_hook", cpex.PayloadMyType, payload, ext, nil) +result, ct, bg, _ := engine.InvokeByName("my.hook", cpex.PayloadMyType, payload, ext, nil) // Deserialize modified payload from result modified, _ := cpex.DeserializePayload[MyPayload](result) ``` -**Total: 5 touch points** — Rust struct, FFI constant, deserialize arm, serialize arm, Go struct. No framework registration or config changes needed. +**Total: 5 touch points**: Rust struct, FFI constant, deserialize arm, serialize arm, Go struct. No framework registration or config changes needed. ## Tests @@ -356,12 +424,13 @@ modified, _ := cpex.DeserializePayload[MyPayload](result) # Build the Rust library first cargo build --release -p cpex-ffi -# Run Go tests +# Run Go tests (add -race for the race detector) cd go/cpex && go test -v ./... ``` ## See Also -- [Go Demo Examples](../../examples/go-demo/README.md) — runnable demos with YAML configs -- [Rust Core README](../../crates/README.md) — core runtime documentation -- [Rust Examples](../../crates/cpex-core/examples/README.md) — native Rust examples +- [Go Demo Examples](../../examples/go-demo/README.md): runnable demos with YAML configs +- [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 diff --git a/go/cpex/abi.go b/go/cpex/abi.go index 4a40efbf..bee51ec0 100644 --- a/go/cpex/abi.go +++ b/go/cpex/abi.go @@ -8,9 +8,9 @@ // On package init, calls cpex_ffi_abi_version() and panics if the // linked libcpex_ffi reports an ABI version different from what this // Go binding was generated against. A mismatch means the C surface -// the bindings expect is not the one libcpex_ffi exposes — every -// other cgo call in this package would have undefined behavior, so -// failing loud at init is preferred over silent corruption later. +// the bindings expect is not the one libcpex_ffi exposes, so every +// other cgo call in this package would have undefined behavior. +// Failing loudly at init is preferred over silent corruption later. // // Bumping expectedFFIABIVersion is required (and only required) when // the Rust crate bumps FFI_ABI_VERSION. See crates/cpex-ffi/src/lib.rs @@ -21,8 +21,8 @@ package cpex /* #include -// Duplicated from ffi.go / manager.go preambles — see the note in -// manager.go about cgo not merging declarations across files. +// Duplicated from the ffi.go / engine.go preambles; see the note in +// engine.go about cgo not merging declarations across files. extern uint32_t cpex_ffi_abi_version(void); */ import "C" @@ -32,22 +32,35 @@ 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 = 2 +const expectedFFIABIVersion uint32 = 3 + +// checkFFIABIVersion returns an error when the ABI version reported by +// the linked libcpex_ffi differs from the one this binding expects. +func checkFFIABIVersion(actual uint32) error { + if actual == expectedFFIABIVersion { + return nil + } + return fmt.Errorf( + "cpex: FFI ABI version mismatch: Go binding expects %d, "+ + "linked libcpex_ffi reports %d. Upgrade github.com/"+ + "contextforge-org/cpex/go/cpex "+ + "to a version generated against libcpex_ffi ABI %d, "+ + "or rebuild libcpex_ffi from a CPEX commit whose "+ + "FFI_ABI_VERSION is %d", + expectedFFIABIVersion, actual, actual, expectedFFIABIVersion, + ) +} + +// linkedFFIABIVersion is the ABI version the linked libcpex_ffi reports. +func linkedFFIABIVersion() uint32 { + return uint32(C.cpex_ffi_abi_version()) +} func init() { - actual := uint32(C.cpex_ffi_abi_version()) - if actual != expectedFFIABIVersion { - // Intentional panic: init() has no error return, and a mismatched - // ABI means every cgo call in this package is already unsound — - // there is no error value we could hand back to a caller. - panic(fmt.Sprintf( - "cpex: FFI ABI version mismatch — Go binding expects %d, "+ - "linked libcpex_ffi reports %d. Upgrade github.com/"+ - "contextforge-org/cpex/go/cpex "+ - "to a version generated against libcpex_ffi ABI %d, "+ - "or rebuild libcpex_ffi from a CPEX commit whose "+ - "FFI_ABI_VERSION is %d.", - expectedFFIABIVersion, actual, actual, expectedFFIABIVersion, - )) + // Intentional panic: init() has no error return, and a mismatched + // ABI means every cgo call in this package is already unsound. + // There is no error value we could hand back to a caller. + if err := checkFFIABIVersion(linkedFFIABIVersion()); err != nil { + panic(err.Error()) } } diff --git a/go/cpex/abi_test.go b/go/cpex/abi_test.go new file mode 100644 index 00000000..84261496 --- /dev/null +++ b/go/cpex/abi_test.go @@ -0,0 +1,46 @@ +// Location: ./go/cpex/abi_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo + +package cpex + +import ( + "fmt" + "strings" + "testing" +) + +func TestCheckFFIABIVersionAcceptsExpected(t *testing.T) { + if err := checkFFIABIVersion(expectedFFIABIVersion); err != nil { + t.Errorf("expected no error for ABI %d, got %v", expectedFFIABIVersion, err) + } +} + +// A library built at an older ABI is rejected with a message naming +// both versions. +func TestCheckFFIABIVersionRejectsMismatch(t *testing.T) { + err := checkFFIABIVersion(2) + if err == nil { + t.Fatal("expected an error for ABI 2") + } + msg := err.Error() + for _, want := range []string{ + "ABI version mismatch", + fmt.Sprintf("expects %d", expectedFFIABIVersion), + "reports 2", + } { + if !strings.Contains(msg, want) { + t.Errorf("error %q does not contain %q", msg, want) + } + } +} + +func TestLinkedLibraryReportsABI3(t *testing.T) { + if got := linkedFFIABIVersion(); got != 3 { + t.Errorf("expected linked libcpex_ffi ABI 3, got %d", got) + } + if expectedFFIABIVersion != 3 { + t.Errorf("expected binding ABI 3, got %d", expectedFFIABIVersion) + } +} diff --git a/go/cpex/acceptance_test.go b/go/cpex/acceptance_test.go new file mode 100644 index 00000000..9a1c8363 --- /dev/null +++ b/go/cpex/acceptance_test.go @@ -0,0 +1,521 @@ +// Location: ./go/cpex/acceptance_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Acceptance flows through the FFI, the way a Go host drives them: +// config load, JWT identity, delegation, a Cedar and an OPA decision, +// and session labels. Identity and the tool hook run in one +// InvokeResolved call so inbound tokens reach the delegator. +// +// The JWT issuer and the OAuth token endpoint are loopback httptest +// servers, so engines opt in with EnableAPL(WithPrivateDestinations()). +// Tokens are HS256 against an `oct` JWKS key, which keeps the tests free +// of key files. +// +// The Valkey test is opt in: set CPEX_TEST_VALKEY_URL (for example +// redis://127.0.0.1:6390) to run it against a live server. +// +// Build the staticlib first: +// +// cargo build --release -p cpex-ffi +// go test -v -run Acceptance ./... + +package cpex + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" +) + +const ( + accIssuer = "https://idp.acceptance.test" + accAudience = "cpex-acceptance" + accKID = "acceptance-key" +) + +var accSecret = []byte("acceptance-only-hs256-secret-0123456789") + +// --------------------------------------------------------------------------- +// Loopback issuer and token endpoint +// --------------------------------------------------------------------------- + +func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// mintJWT signs an HS256 token for sub with roles. A negative expIn +// mints an expired token; a non-nil key signs with that key instead. +func mintJWT(t *testing.T, sub string, roles []string, expIn time.Duration, key []byte) string { + t.Helper() + if key == nil { + key = accSecret + } + now := time.Now() + header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT", "kid": accKID}) + claims, _ := json.Marshal(map[string]any{ + "iss": accIssuer, + "aud": accAudience, + "sub": sub, + "iat": now.Add(-5 * time.Second).Unix(), + "exp": now.Add(expIn).Unix(), + "roles": roles, + }) + signing := b64url(header) + "." + b64url(claims) + mac := hmac.New(sha256.New, key) + mac.Write([]byte(signing)) + return signing + "." + b64url(mac.Sum(nil)) +} + +// mockIdP serves the JWKS and an RFC 8693 token endpoint that grants +// audience "workday-api" and refuses every other audience. +type mockIdP struct { + srv *httptest.Server + + mu sync.Mutex + jwksFetches int + exchanges []url.Values +} + +func newMockIdP(t *testing.T) *mockIdP { + t.Helper() + m := &mockIdP{} + mux := http.NewServeMux() + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + m.mu.Lock() + m.jwksFetches++ + m.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"keys": []map[string]string{{ + "kty": "oct", "kid": accKID, "alg": "HS256", "use": "sig", "k": b64url(accSecret), + }}}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + return + } + m.mu.Lock() + m.exchanges = append(m.exchanges, r.PostForm) + m.mu.Unlock() + if r.PostForm.Get("audience") != "workday-api" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_target"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": "acceptance-only-workday-token", + "token_type": "Bearer", + "expires_in": 300, + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + }) + }) + m.srv = httptest.NewServer(mux) + t.Cleanup(m.srv.Close) + return m +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func (m *mockIdP) fetches() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.jwksFetches +} + +func (m *mockIdP) tokenExchanges() []url.Values { + m.mu.Lock() + defer m.mu.Unlock() + return append([]url.Values(nil), m.exchanges...) +} + +// idpPlugin is the identity/jwt plugin block for the mock issuer. +func idpPlugin(jwksURL string, performHTTP bool) string { + caps := "" + if performHTTP { + caps = " capabilities: [perform_http]\n" + } + return ` +plugins: + - name: idp + kind: identity/jwt + hooks: [identity.resolve] +` + caps + ` config: + claim_mapper: standard + trusted_issuers: + - issuer: ` + accIssuer + ` + audiences: [` + accAudience + `] + algorithms: [HS256] + decoding_key: + kind: jwks_url + url: "` + jwksURL + `" + insecure_http: true +` +} + +// accEngine builds an engine with builtins and the loopback opt-in, +// loads yaml and initializes it. +func accEngine(t *testing.T, yaml string, opts ...APLOption) *PolicyEngine { + t.Helper() + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault: %v", err) + } + t.Cleanup(eng.Shutdown) + if err := eng.EnableAPL(opts...); err != nil { + t.Fatalf("EnableAPL: %v", err) + } + if err := eng.LoadConfig(yaml); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + return eng +} + +// callTool resolves the bearer token and runs cmf.tool_pre_invoke for +// tool in one InvokeResolved call. session is the agent session id, or +// empty for none. +func callTool(t *testing.T, eng *PolicyEngine, token, tool string, args map[string]any, session string) *PipelineResult { + t.Helper() + identity := NewIdentityPayload(TokenSourceBearer, map[string]string{"authorization": "Bearer " + token}) + ext := &Extensions{Meta: &MetaExtension{EntityType: "tool", EntityName: tool}} + if session != "" { + ext.Agent = &AgentExtension{SessionID: session} + } + payload := MessagePayload{Message: NewMessage("assistant", NewToolCallPart(ToolCall{ + ToolCallID: "acc-1", Name: tool, Arguments: args, + }))} + res, ct, bg, err := eng.InvokeResolved(identity, "cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, nil) + if err != nil { + t.Fatalf("InvokeResolved(%s): %v", tool, err) + } + if bg != nil { + bg.Close() + } + if ct != nil { + ct.Close() + } + return res +} + +func wantAllowed(t *testing.T, label string, r *PipelineResult) { + t.Helper() + if r.IsDenied() { + t.Errorf("%s: expected allowed, got violation %+v", label, r.Violation) + } +} + +func wantDenied(t *testing.T, label string, r *PipelineResult, code string) { + t.Helper() + if !r.IsDenied() { + t.Errorf("%s: expected denied with %q, got allowed", label, code) + return + } + if code != "" && (r.Violation == nil || !strings.Contains(r.Violation.Code, code)) { + t.Errorf("%s: expected violation code containing %q, got %+v", label, code, r.Violation) + } +} + +// --------------------------------------------------------------------------- +// Config load +// --------------------------------------------------------------------------- + +// TestAcceptanceRemovedConfigKeysFailToLoad checks 0.2.x config shapes +// are refused at load. The C ABI returns a code, not the message; the +// message naming the key goes to the library's tracing log. +func TestAcceptanceRemovedConfigKeysFailToLoad(t *testing.T) { + cases := []struct { + name, yaml string + want error + }{ + {"global.policies", "global:\n policies:\n - name: legacy\n", ErrCpexParse}, + {"global.apl", "global:\n apl:\n pdp:\n - kind: cel\n", ErrCpexParse}, + {"legacy hook name", "engine_settings:\n dispatch: hooks\nplugins:\n - name: idp\n kind: identity/jwt\n hooks: [identity_resolve]\n", ErrCpexParse}, + {"removed builtin", "engine_settings:\n dispatch: hooks\nplugins:\n - name: pii\n kind: validator/pii-scan\n hooks: [cmf.tool_pre_invoke]\n", ErrCpexPipeline}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault: %v", err) + } + defer eng.Shutdown() + if err := eng.EnableAPL(); err != nil { + t.Fatalf("EnableAPL: %v", err) + } + if err := eng.LoadConfig(c.yaml); !errors.Is(err, c.want) { + t.Errorf("expected %v, got %v", c.want, err) + } + if got := eng.PluginCount(); got != 0 { + t.Errorf("a refused config must not install plugins; got %d", got) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +const accIdentityRoutes = ` +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - "require(authenticated)" + - "require(role.hr)" +` + +func TestAcceptanceJWTIdentityGatesByRole(t *testing.T) { + idp := newMockIdP(t) + eng := accEngine(t, idpPlugin(idp.srv.URL+"/jwks", true)+accIdentityRoutes, WithPrivateDestinations()) + if got := idp.fetches(); got != 1 { + t.Errorf("expected one JWKS fetch at Initialize, got %d", got) + } + args := map[string]any{"employee_id": "e-1"} + + wantAllowed(t, "hr", callTool(t, eng, mintJWT(t, "alice", []string{"hr"}, 10*time.Minute, nil), "get_compensation", args, "")) + wantDenied(t, "engineer", callTool(t, eng, mintJWT(t, "evan", []string{"engineer"}, 10*time.Minute, nil), "get_compensation", args, ""), "") + wantDenied(t, "expired", callTool(t, eng, mintJWT(t, "alice", []string{"hr"}, -time.Hour, nil), "get_compensation", args, ""), "auth.token_expired") + wantDenied(t, "forged", callTool(t, eng, mintJWT(t, "alice", []string{"hr"}, 10*time.Minute, []byte("not-the-issuer-key-not-the-issuer")), "get_compensation", args, ""), "auth.signature_invalid") +} + +func TestAcceptanceLoopbackJWKSRefusedWithoutOptIn(t *testing.T) { + idp := newMockIdP(t) + eng := accEngine(t, idpPlugin(idp.srv.URL+"/jwks", true)+accIdentityRoutes) + res := callTool(t, eng, mintJWT(t, "alice", []string{"hr"}, 10*time.Minute, nil), "get_compensation", map[string]any{}, "") + if got := idp.fetches(); got != 0 { + t.Errorf("expected no JWKS fetch without the opt-in, got %d", got) + } + wantDenied(t, "blocked jwks", res, "auth.jwks_unavailable") +} + +func TestAcceptanceJWKSWithoutPerformHTTPFailsToStart(t *testing.T) { + idp := newMockIdP(t) + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault: %v", err) + } + defer eng.Shutdown() + if err := eng.EnableAPL(WithPrivateDestinations()); err != nil { + t.Fatalf("EnableAPL: %v", err) + } + if err := eng.LoadConfig(idpPlugin(idp.srv.URL+"/jwks", false) + accIdentityRoutes); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if err := eng.Initialize(); err == nil { + t.Fatal("expected Initialize to fail without perform_http") + } + if got := idp.fetches(); got != 0 { + t.Errorf("expected no JWKS fetch, got %d", got) + } +} + +// --------------------------------------------------------------------------- +// Delegation +// --------------------------------------------------------------------------- + +func TestAcceptanceOAuthDelegation(t *testing.T) { + idp := newMockIdP(t) + yaml := idpPlugin(idp.srv.URL+"/jwks", true) + ` + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http] + config: + token_endpoint: "` + idp.srv.URL + `/token" + client_id: cpex-gateway + client_secret_source: {kind: literal, secret: acceptance-only} + insecure_http: true + +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api)" + - "require(delegation.granted)" + + - tool: read_repo + authentication: [idp] + authorization: + pre_invocation: + - "require(authenticated)" + - "delegate(workday-oauth, target: github-api, audience: github-api)" + - "require(delegation.granted)" +` + eng := accEngine(t, yaml, WithPrivateDestinations()) + alice := mintJWT(t, "alice", []string{"hr"}, 10*time.Minute, nil) + + wantAllowed(t, "hr delegated", callTool(t, eng, alice, "get_compensation", map[string]any{"employee_id": "e-1"}, "")) + ex := idp.tokenExchanges() + if len(ex) != 1 { + t.Fatalf("expected one token exchange, got %d", len(ex)) + } + if got := ex[0].Get("subject_token"); got != alice { + t.Errorf("the inbound bearer token must be the subject_token; got %q", got) + } + if got := ex[0].Get("grant_type"); got != "urn:ietf:params:oauth:grant-type:token-exchange" { + t.Errorf("unexpected grant_type %q", got) + } + + wantDenied(t, "engineer denied before delegation", + callTool(t, eng, mintJWT(t, "evan", []string{"engineer"}, 10*time.Minute, nil), "get_compensation", map[string]any{}, ""), "") + if got := len(idp.tokenExchanges()); got != 1 { + t.Errorf("a role denial must not reach the token endpoint; exchanges = %d", got) + } + + wantDenied(t, "audience refused", callTool(t, eng, alice, "read_repo", map[string]any{"repo": "cpex"}, ""), "") +} + +// --------------------------------------------------------------------------- +// PDP decisions on the resolved subject +// --------------------------------------------------------------------------- + +func TestAcceptanceCedarDecision(t *testing.T) { + idp := newMockIdP(t) + yaml := ` +global: + pdp: + - kind: cedar-direct + policy_text: | + @id("hr-read-compensation") + permit(principal, action == Action::"read", resource) + when { principal.roles.contains("hr") }; + + @id("contractor-forbid") + forbid(principal, action, resource) + when { principal.roles.contains("contractor") }; +` + idpPlugin(idp.srv.URL+"/jwks", true) + ` +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - cedar: + action: 'Action::"read"' + resource: + type: Compensation + id: e-1001 +` + eng := accEngine(t, yaml, WithPrivateDestinations()) + args := map[string]any{"employee_id": "e-1001"} + ten := 10 * time.Minute + + wantAllowed(t, "hr", callTool(t, eng, mintJWT(t, "alice", []string{"hr"}, ten, nil), "get_compensation", args, "")) + wantDenied(t, "engineer", callTool(t, eng, mintJWT(t, "evan", []string{"engineer"}, ten, nil), "get_compensation", args, ""), "cedar.default_deny") + wantDenied(t, "contractor", callTool(t, eng, mintJWT(t, "carl", []string{"hr", "contractor"}, ten, nil), "get_compensation", args, ""), "contractor-forbid") +} + +func TestAcceptanceOPADecision(t *testing.T) { + idp := newMockIdP(t) + yaml := ` +global: + pdp: + - kind: opa + modules: + - | + package cpex.acceptance + default allow := false + allow if { + input.subject.id == "alice" + input.args.amount <= 100 + } +` + idpPlugin(idp.srv.URL+"/jwks", true) + ` +routes: + - tool: transfer + authentication: [idp] + authorization: + pre_invocation: + - opa: + query: "data.cpex.acceptance.allow" +` + eng := accEngine(t, yaml, WithPrivateDestinations()) + ten := 10 * time.Minute + alice := mintJWT(t, "alice", []string{"finance"}, ten, nil) + + wantAllowed(t, "alice small", callTool(t, eng, alice, "transfer", map[string]any{"amount": 50}, "")) + wantDenied(t, "alice large", callTool(t, eng, alice, "transfer", map[string]any{"amount": 500}, ""), "opa") + wantDenied(t, "evan", callTool(t, eng, mintJWT(t, "evan", []string{"finance"}, ten, nil), "transfer", map[string]any{"amount": 50}, ""), "opa") +} + +// --------------------------------------------------------------------------- +// Session labels +// --------------------------------------------------------------------------- + +const accSessionRoutes = ` +routes: + - tool: get_compensation + authentication: [idp] + authorization: + pre_invocation: + - "require(role.hr)" + - "taint(secret, session)" + + - tool: send_email + authentication: [idp] + authorization: + pre_invocation: + - "require(authenticated)" + - "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')" +` + +func TestAcceptanceSessionLabelsMemoryStore(t *testing.T) { + idp := newMockIdP(t) + eng := accEngine(t, idpPlugin(idp.srv.URL+"/jwks", true)+accSessionRoutes, WithPrivateDestinations()) + ten := 10 * time.Minute + alice := mintJWT(t, "alice", []string{"hr"}, ten, nil) + email := map[string]any{"to": "bob@corp.example"} + + wantAllowed(t, "email before taint", callTool(t, eng, alice, "send_email", email, "sess-1")) + wantAllowed(t, "read taints", callTool(t, eng, alice, "get_compensation", map[string]any{"employee_id": "e-1"}, "sess-1")) + wantDenied(t, "email after taint", callTool(t, eng, alice, "send_email", email, "sess-1"), "session_tainted") + wantAllowed(t, "fresh session", callTool(t, eng, alice, "send_email", email, "sess-2")) + wantAllowed(t, "other subject", callTool(t, eng, mintJWT(t, "evan", []string{"engineer"}, ten, nil), "send_email", email, "sess-1")) +} + +// TestAcceptanceSessionLabelsSharedValkey taints a session on one engine +// and checks a second engine sharing the Valkey store sees the label. +// Skipped unless CPEX_TEST_VALKEY_URL names a reachable server. +func TestAcceptanceSessionLabelsSharedValkey(t *testing.T) { + valkeyURL := os.Getenv("CPEX_TEST_VALKEY_URL") + if valkeyURL == "" { + t.Skip("set CPEX_TEST_VALKEY_URL (e.g. redis://127.0.0.1:6390) to run") + } + idp := newMockIdP(t) + yaml := ` +global: + session_store: + kind: valkey + endpoint: "` + valkeyURL + `" + ttl_seconds: 300 +` + idpPlugin(idp.srv.URL+"/jwks", true) + accSessionRoutes + first := accEngine(t, yaml, WithPrivateDestinations()) + second := accEngine(t, yaml, WithPrivateDestinations()) + + ten := 10 * time.Minute + alice := mintJWT(t, "alice", []string{"hr"}, ten, nil) + email := map[string]any{"to": "bob@corp.example"} + // A per-run session id keeps reruns against the same server independent. + session := fmt.Sprintf("acc-%d", time.Now().UnixNano()) + + wantAllowed(t, "second engine before taint", callTool(t, second, alice, "send_email", email, session)) + wantAllowed(t, "first engine taints", callTool(t, first, alice, "get_compensation", map[string]any{"employee_id": "e-1"}, session)) + 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")) +} diff --git a/go/cpex/apl.go b/go/cpex/apl.go index 85032a85..0c2eae2b 100644 --- a/go/cpex/apl.go +++ b/go/cpex/apl.go @@ -3,11 +3,12 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// APL (Authorization Policy Language) wiring. +// APL (Attribute Policy Language) and builtin wiring. // -// EnableAPL registers the bundled APL plugin/PDP factories and installs -// the APL config visitor on the manager via the cpex_apl_install FFI -// entry point. Call it after NewPluginManagerDefault and before +// EnableAPL registers the bundled plugin, PDP and session-store +// factories, installs the APL config visitor, the outbound HTTP +// transport, and the secret providers, via the cpex_apl_install FFI +// entry point. Call it after NewPolicyEngineDefault and before // LoadConfig so that LoadConfig walks the config's `apl:` blocks and // installs per-route handlers. @@ -20,43 +21,89 @@ import ( /* #include -// Opaque handle — same typedef as manager.go / ffi.go. Duplicated here +// Opaque handle, same typedef as engine.go / ffi.go. Duplicated here // because cgo does NOT merge declarations across files' preambles; see -// the note in manager.go. Edit all copies together if the signature +// the note in engine.go. Edit all copies together if the signature // changes. -typedef void* CpexManager; +typedef void* CpexEngine; -extern int cpex_apl_install(CpexManager mgr); +extern int cpex_apl_install(CpexEngine engine, uint32_t flags); */ import "C" -// EnableAPL registers the bundled APL plugin and PDP factories and -// installs the APL config visitor on the manager (in-process defaults: -// memory session store, default baseline capabilities). +// aplAllowPrivateDestinations mirrors CPEX_APL_ALLOW_PRIVATE_DESTINATIONS +// in crates/cpex-ffi/src/apl.rs. +const aplAllowPrivateDestinations uint32 = 1 + +// APLOption configures EnableAPL. +type APLOption func(*aplSettings) + +type aplSettings struct { + flags uint32 +} + +// WithPrivateDestinations lets builtins reach private, loopback and +// link-local addresses, for example a JWKS endpoint on localhost or an +// in-cluster Vault. Without it such requests are refused and builtins +// report their egress-denied codes. This is a host decision: no policy +// document can turn it on. +func WithPrivateDestinations() APLOption { + return func(s *aplSettings) { + s.flags |= aplAllowPrivateDestinations + } +} + +// aplFlags folds options into the cpex_apl_install flag word. +func aplFlags(opts ...APLOption) uint32 { + var s aplSettings + for _, opt := range opts { + if opt != nil { + opt(&s) + } + } + return s.flags +} + +// EnableAPL installs the bundled builtins on the engine: the plugin, +// PDP and session-store factories, the APL config visitor, one +// outbound HTTP transport, and the secret providers (env, file, vault), +// with Vault using the same transport. +// +// Bundled plugin kinds: identity/jwt, identity/api-key, delegator/oauth, +// elicitation/ciba. Bundled PDP kinds: cedar-direct, cel, opa. Bundled +// session store: valkey (the in-process memory store stays the default). +// Plugins that reach the network (jwks_url, OAuth, CIBA, Vault) must +// declare the `perform_http` capability. // -// Bundled plugin kinds: validator/pii-scan, audit/logger, identity/jwt, -// delegator/oauth. Bundled PDP kind: cedar-direct. +// Private and loopback destinations are blocked unless the call passes +// WithPrivateDestinations. // -// Ordering: call after NewPluginManagerDefault and before LoadConfig. -// The one-shot NewPluginManager(yaml) constructor loads config during -// creation and therefore does NOT support APL — use the default-manager +// Ordering: call after NewPolicyEngineDefault and before LoadConfig. +// The one-shot NewPolicyEngine(yaml) constructor loads config during +// creation and therefore does NOT support APL; use the default-engine // flow instead: // -// mgr, err := NewPluginManagerDefault() +// engine, err := NewPolicyEngineDefault() // if err != nil { ... } -// mgr.EnableAPL() -// mgr.LoadConfig(yaml) -// mgr.Initialize() +// engine.EnableAPL() +// engine.LoadConfig(yaml) +// engine.Initialize() +// +// Idempotent per engine: once a call has succeeded, later calls do +// nothing and return nil, whatever their options. A call that panicked +// stays failed: it and every later call on the engine return an error +// wrapping ErrCpexPanic, because the engine may be half installed. +// Discard the engine and create a new one. // // On failure the returned error wraps a typed sentinel -// (ErrCpexInvalidHandle, ErrCpexPanic). -func (m *PluginManager) EnableAPL() error { +// (ErrCpexInvalidHandle, ErrCpexInvalidInput, ErrCpexPanic). +func (m *PolicyEngine) EnableAPL(opts ...APLOption) error { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { return fmt.Errorf("EnableAPL: %w", ErrCpexInvalidHandle) } - rc := C.cpex_apl_install(m.handle) + rc := C.cpex_apl_install(m.handle, C.uint32_t(aplFlags(opts...))) return errorFromRC(int(rc), "EnableAPL") } diff --git a/go/cpex/apl_test.go b/go/cpex/apl_test.go index a8f33942..d9a84596 100644 --- a/go/cpex/apl_test.go +++ b/go/cpex/apl_test.go @@ -3,8 +3,8 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// Tests for APL wiring (EnableAPL). Run against the real Rust runtime -// via cgo; build the staticlib first: +// Tests for builtin wiring (EnableAPL). Run against the real Rust +// runtime via cgo; build the staticlib first: // // cargo build --release -p cpex-ffi // go test -v ./... @@ -16,58 +16,204 @@ import ( "testing" ) +// aplJWTConfig uses the shipped identity/jwt builtin. The JWKS URL +// points at a closed local port: key fetch soft-fails at Initialize, so +// the config loads and initializes without a network. +const aplJWTConfig = ` +plugins: + - name: jwt + kind: identity/jwt + hooks: [identity.resolve] + capabilities: [perform_http] + config: + role: user + header: Authorization + trusted_issuers: + - issuer: "https://idp.test.local" + audiences: ["test-api"] + algorithms: ["ES256"] + decoding_key: + kind: jwks_url + url: "http://127.0.0.1:9/jwks" + insecure_http: true + claim_mapper: standard +global: + authentication: [jwt] +routes: + - tool: get_weather + authorization: + pre_invocation: + - "require(role.hr)" +` + // TestEnableAPLLoadsAplConfig drives the documented APL flow: -// NewPluginManagerDefault → EnableAPL → LoadConfig (APL-annotated) → -// Initialize. The bundled `audit/logger` factory must instantiate, so -// the cmf.tool_pre_invoke hook is registered after load. +// NewPolicyEngineDefault, EnableAPL, LoadConfig (APL-annotated), +// Initialize. The bundled identity/jwt factory must instantiate, and +// the route's policy handler must sit on cmf.tool_pre_invoke. func TestEnableAPLLoadsAplConfig(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.EnableAPL(); err != nil { + if err := eng.EnableAPL(); err != nil { t.Fatalf("EnableAPL failed: %v", err) } - - yaml := ` -plugins: - - name: auditor - kind: audit/logger - hooks: [cmf.tool_pre_invoke] -routes: - - tool: get_weather - authorization: - pre_invocation: - - "plugin(auditor)" -` - if err := mgr.LoadConfig(yaml); err != nil { + if err := eng.LoadConfig(aplJWTConfig); err != nil { t.Fatalf("LoadConfig failed: %v", err) } - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } - if mgr.PluginCount() < 1 { - t.Errorf("expected at least 1 plugin, got %d", mgr.PluginCount()) + if got := eng.PluginCount(); got != 1 { + t.Errorf("expected 1 plugin, got %d", got) + } + if !eng.HasHooksFor("identity.resolve") { + t.Error("expected identity.resolve hook registered") } - if !mgr.HasHooksFor("cmf.tool_pre_invoke") { + if !eng.HasHooksFor("cmf.tool_pre_invoke") { t.Error("expected cmf.tool_pre_invoke hook registered after APL load") } } +// TestEnableAPLWithPrivateDestinations checks the option is accepted by +// the FFI (an unknown flag bit would be rejected as invalid input) and +// the engine still loads and initializes. +func TestEnableAPLWithPrivateDestinations(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + defer eng.Shutdown() + + if err := eng.EnableAPL(WithPrivateDestinations()); err != nil { + t.Fatalf("EnableAPL(WithPrivateDestinations()) failed: %v", err) + } + if err := eng.LoadConfig(aplJWTConfig); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } +} + +func TestAPLFlags(t *testing.T) { + if got := aplFlags(); got != 0 { + t.Errorf("no options: expected flags 0, got %#x", got) + } + if got := aplFlags(WithPrivateDestinations()); got != aplAllowPrivateDestinations { + t.Errorf("WithPrivateDestinations: expected flags %#x, got %#x", aplAllowPrivateDestinations, got) + } + if got := aplFlags(WithPrivateDestinations(), WithPrivateDestinations(), nil); got != aplAllowPrivateDestinations { + t.Errorf("repeated and nil options: expected flags %#x, got %#x", aplAllowPrivateDestinations, got) + } +} + +// TestEnableAPLIsIdempotent verifies a second call is a no-op: the +// config still loads with a single plugin. +func TestEnableAPLIsIdempotent(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + defer eng.Shutdown() + + if err := eng.EnableAPL(); err != nil { + t.Fatalf("first EnableAPL failed: %v", err) + } + if err := eng.EnableAPL(WithPrivateDestinations()); err != nil { + t.Fatalf("second EnableAPL failed: %v", err) + } + if err := eng.LoadConfig(aplJWTConfig); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if got := eng.PluginCount(); got != 1 { + t.Errorf("expected 1 plugin, got %d", got) + } +} + // TestEnableAPLAfterShutdown verifies the typed handle error is returned -// when EnableAPL is called on a shut-down manager. +// when EnableAPL is called on a shut-down engine. func TestEnableAPLAfterShutdown(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - mgr.Shutdown() + eng.Shutdown() - err = mgr.EnableAPL() + err = eng.EnableAPL() if !errors.Is(err, ErrCpexInvalidHandle) { t.Errorf("expected ErrCpexInvalidHandle, got %v", err) } } + +// TestPolicyEngineLifecycleInvokesToolPreInvoke covers the common host +// flow end to end: create, load a policy, invoke cmf.tool_pre_invoke, +// shut down. One route denies, another allows. +func TestPolicyEngineLifecycleInvokesToolPreInvoke(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + defer eng.Shutdown() + + if err := eng.EnableAPL(); err != nil { + t.Fatalf("EnableAPL failed: %v", err) + } + yaml := ` +routes: + - tool: delete_repo + authorization: + pre_invocation: + - "deny('deleting repositories is blocked', 'demo.delete_blocked')" + - tool: get_weather + authorization: + pre_invocation: + - "allow" +` + if err := eng.LoadConfig(yaml); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + if !eng.HasHooksFor("cmf.tool_pre_invoke") { + t.Fatal("expected cmf.tool_pre_invoke hook registered") + } + + invoke := func(tool string) *TypedPipelineResult[MessagePayload] { + t.Helper() + payload := MessagePayload{Message: NewMessage("assistant", + NewToolCallPart(ToolCall{ToolCallID: "tc-1", Name: tool}), + )} + ext := &Extensions{Meta: &MetaExtension{EntityType: "tool", EntityName: tool}} + result, ct, bg, err := Invoke[MessagePayload]( + eng, "cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, nil, + ) + if err != nil { + t.Fatalf("Invoke(%s) failed: %v", tool, err) + } + bg.Close() + ct.Close() + return result + } + + denied := invoke("delete_repo") + if !denied.IsDenied() { + t.Fatal("expected delete_repo to be denied") + } + if denied.Violation.Code != "demo.delete_blocked" { + t.Errorf("expected violation code demo.delete_blocked, got %q", denied.Violation.Code) + } + + if allowed := invoke("get_weather"); allowed.IsDenied() { + t.Errorf("expected get_weather to be allowed, got violation %+v", allowed.Violation) + } + + eng.Shutdown() + if eng.IsInitialized() { + t.Error("expected engine not initialized after Shutdown") + } +} diff --git a/go/cpex/cmf.go b/go/cpex/cmf.go index 2b3438c2..4db7882d 100644 --- a/go/cpex/cmf.go +++ b/go/cpex/cmf.go @@ -5,7 +5,7 @@ // // CMF (ContextForge Message Format) types for Go. // -// Mirrors the Rust types in crates/cpex-core/src/cmf/. The Message +// Mirrors the Rust types in praxis_policy_core::cmf. The Message // struct carries typed content parts (text, tool calls, resources, // media, etc.) without extensions — those are passed separately. // @@ -15,10 +15,13 @@ package cpex -import "github.com/vmihailenco/msgpack/v5" +import ( + "github.com/vmihailenco/msgpack/v5" + "github.com/vmihailenco/msgpack/v5/msgpcode" +) // MessagePayload wraps a Message for FFI transport. -// Matches Rust's cpex_core::cmf::MessagePayload. +// Matches Rust's praxis_policy_core::cmf::MessagePayload. type MessagePayload struct { Message Message `msgpack:"message"` } @@ -26,10 +29,12 @@ type MessagePayload struct { // Message is the ContextForge Message Format (CMF) message. // No extensions — those are passed separately to the plugin pipeline. type Message struct { - SchemaVersion string `msgpack:"schema_version"` - Role string `msgpack:"role"` - Content []ContentPart `msgpack:"content"` - Channel string `msgpack:"channel,omitempty"` + SchemaVersion string `msgpack:"schema_version"` + Role string `msgpack:"role"` + // Content is omitted when empty: Rust defaults a missing list but + // rejects a nil one. + Content []ContentPart `msgpack:"content,omitempty"` + Channel string `msgpack:"channel,omitempty"` } // NewMessage creates a Message with the default schema version. @@ -270,7 +275,7 @@ type Resource struct { Description string `msgpack:"description,omitempty"` ResourceType string `msgpack:"resource_type"` Content string `msgpack:"content,omitempty"` - Blob []byte `msgpack:"blob,omitempty"` + Blob ByteArray `msgpack:"blob,omitempty"` MimeType string `msgpack:"mime_type,omitempty"` SizeBytes *uint64 `msgpack:"size_bytes,omitempty"` Annotations map[string]any `msgpack:"annotations,omitempty"` @@ -337,6 +342,59 @@ type DocumentSource struct { Title string `msgpack:"title,omitempty"` } +// ByteArray is binary data on the wire format Rust uses for Vec: a +// MessagePack array of integers, not a bin value. It decodes either +// form. A []byte value assigns to it directly. +type ByteArray []byte + +// EncodeMsgpack writes b as an array of uint8 values. +func (b ByteArray) EncodeMsgpack(enc *msgpack.Encoder) error { + if b == nil { + return enc.EncodeNil() + } + if err := enc.EncodeArrayLen(len(b)); err != nil { + return err + } + for _, v := range b { + if err := enc.EncodeUint8(v); err != nil { + return err + } + } + return nil +} + +// DecodeMsgpack reads an array of integers, a bin value or nil. +func (b *ByteArray) DecodeMsgpack(dec *msgpack.Decoder) error { + code, err := dec.PeekCode() + if err != nil { + return err + } + if msgpcode.IsBin(code) || msgpcode.IsString(code) || code == msgpcode.Nil { + raw, err := dec.DecodeBytes() + if err != nil { + return err + } + *b = raw + return nil + } + n, err := dec.DecodeArrayLen() + if err != nil { + return err + } + if n < 0 { + *b = nil + return nil + } + out := make([]byte, n) + for i := range out { + if out[i], err = dec.DecodeUint8(); err != nil { + return err + } + } + *b = out + return nil +} + // decodeAs re-encodes a decoded msgpack value and unmarshals it into a // typed struct, letting the struct's msgpack tags drive field selection. // Replaces 11 hand-rolled decoders that each had to enumerate fields diff --git a/go/cpex/constants.go b/go/cpex/constants.go index 3fb39e23..748c917e 100644 --- a/go/cpex/constants.go +++ b/go/cpex/constants.go @@ -22,13 +22,14 @@ const ( PayloadCMFMessage uint8 = 1 // PayloadIdentity is an IdentityPayload — the input/output state of // the identity.resolve hook. Send request headers in; read the - // resolved subject / client / raw credentials back out. + // resolved subject / client back out. Raw token bytes stay in Rust and + // does not echo the request headers. PayloadIdentity uint8 = 2 ) // ContentType values — the discriminator for ContentPart's tagged union. // Wire-compatible with Rust's `#[serde(tag = "content_type")]` enum in -// `crates/cpex-core/src/cmf/`. Every string literal in cmf.go's encoder / +// `praxis_policy_core::cmf`. Every string literal in cmf.go's encoder / // decoder / constructor switches resolves to one of these. const ( ContentTypeText = "text" diff --git a/go/cpex/manager.go b/go/cpex/engine.go similarity index 79% rename from go/cpex/manager.go rename to go/cpex/engine.go index 72f7bdcb..4ba8e30b 100644 --- a/go/cpex/manager.go +++ b/go/cpex/engine.go @@ -1,17 +1,17 @@ -// Location: ./go/cpex/manager.go +// Location: ./go/cpex/engine.go // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor, Fred Araujo // -// PluginManager — Go wrapper for the CPEX plugin runtime. +// PolicyEngine — Go wrapper for the CPEX plugin runtime. // -// Owns the lifecycle of the Rust PluginManager via cgo. Provides +// Owns the lifecycle of the Rust PolicyEngine via cgo. Provides // the public API that Go host systems call to register factories, // load config, initialize plugins, and invoke hooks. // // Lifecycle: // -// NewPluginManagerDefault() → RegisterFactories() → LoadConfig() → Initialize() → InvokeByName() → Shutdown() +// NewPolicyEngineDefault() → RegisterFactories() → LoadConfig() → Initialize() → InvokeByName() → Shutdown() // // Payloads and extensions are serialized to MessagePack when // crossing the FFI boundary. ContextTable and BackgroundTasks @@ -34,7 +34,7 @@ import ( #include // Opaque handles -typedef void* CpexManager; +typedef void* CpexEngine; typedef void* CpexContextTable; typedef void* CpexBackgroundTasks; @@ -55,17 +55,17 @@ typedef void* CpexBackgroundTasks; // - we generate the C header via cbindgen and #include it from // both files. extern int cpex_configure_runtime(int worker_threads); -extern CpexManager cpex_manager_new(const char* config_yaml, int config_len); -extern CpexManager cpex_manager_new_default(); -extern int cpex_load_config(CpexManager mgr, const char* config_yaml, int config_len); -extern int cpex_initialize(CpexManager mgr); -extern void cpex_shutdown(CpexManager mgr); -extern int cpex_has_hooks_for(CpexManager mgr, const char* hook_name, int hook_len); -extern int cpex_plugin_count(CpexManager mgr); -extern int cpex_is_initialized(CpexManager mgr); -extern int cpex_plugin_names(CpexManager mgr, uint8_t** names_msgpack_out, int* names_len_out); +extern CpexEngine cpex_engine_new(const char* config_yaml, int config_len); +extern CpexEngine cpex_engine_new_default(); +extern int cpex_load_config(CpexEngine engine, const char* config_yaml, int config_len); +extern int cpex_initialize(CpexEngine engine); +extern void cpex_shutdown(CpexEngine engine); +extern int cpex_has_hooks_for(CpexEngine engine, const char* hook_name, int hook_len); +extern int cpex_plugin_count(CpexEngine engine); +extern int cpex_is_initialized(CpexEngine engine); +extern int cpex_plugin_names(CpexEngine engine, uint8_t** names_msgpack_out, int* names_len_out); extern int cpex_invoke( - CpexManager mgr, + CpexEngine engine, const char* hook_name, int hook_len, uint8_t payload_type, const uint8_t* payload_msgpack, int payload_len, @@ -76,7 +76,7 @@ extern int cpex_invoke( CpexBackgroundTasks* bg_handle_out ); extern int cpex_invoke_resolved( - CpexManager mgr, + CpexEngine engine, const uint8_t* identity_msgpack, int identity_len, const char* hook_name, int hook_len, uint8_t payload_type, @@ -88,7 +88,7 @@ extern int cpex_invoke_resolved( CpexBackgroundTasks* bg_handle_out ); extern int cpex_wait_background( - CpexManager mgr, + CpexEngine engine, CpexBackgroundTasks bg_handle, uint8_t** errors_msgpack_out, int* errors_len_out ); @@ -98,8 +98,8 @@ extern void cpex_free_bytes(uint8_t* ptr, int len); */ import "C" -// PluginManager manages the lifecycle of CPEX plugins and hook dispatch. -// Wraps the Rust PluginManager — all plugin execution happens in Rust. +// PolicyEngine manages the lifecycle of CPEX plugins and hook dispatch. +// Wraps the Rust PolicyEngine — all plugin execution happens in Rust. // // Concurrency: `mu` serializes lifecycle (Shutdown, finalizer) against // in-flight cgo calls. Operations (Invoke, Initialize, queries) take @@ -107,9 +107,9 @@ import "C" // underlying Rust API is `&self` and ArcSwap-backed, so concurrent // dispatch is safe. `Shutdown` and the GC finalizer take the *write* // lock so the C handle can't be freed while a cgo call is mid-flight. -type PluginManager struct { +type PolicyEngine struct { mu sync.RWMutex - handle C.CpexManager // protected by mu; nil after Shutdown + handle C.CpexEngine // protected by mu; nil after Shutdown } // ContextTable holds per-plugin context state across hook invocations. @@ -121,18 +121,18 @@ type ContextTable struct { // BackgroundTasks holds fire-and-forget task handles. // Opaque handle to Rust-owned data — not serialized. // -// Holds *PluginManager (not the raw C handle) so `Wait()` can check -// `mgr.handle != nil` under the manager's RWMutex — preventing a -// use-after-free if the manager was Shutdown after the invoke that +// Holds *PolicyEngine (not the raw C handle) so `Wait()` can check +// `engine.handle != nil` under the engine's RWMutex — preventing a +// use-after-free if the engine was Shutdown after the invoke that // produced this `BackgroundTasks`. type BackgroundTasks struct { handle C.CpexBackgroundTasks - mgr *PluginManager + engine *PolicyEngine } // ConfigureRuntime sets the worker thread count for the shared tokio -// runtime that backs every PluginManager in the process. Must be -// called before the first NewPluginManager — once a manager has +// runtime that backs every PolicyEngine in the process. Must be +// called before the first NewPolicyEngine — once an engine has // been created the runtime is fixed for the process lifetime. // // Precedence: ConfigureRuntime > CPEX_FFI_WORKER_THREADS env var > @@ -143,10 +143,10 @@ func ConfigureRuntime(workerThreads int) error { return errorFromRC(int(rc), "ConfigureRuntime") } -// finalizeManager is the GC fallback path when the caller forgot to +// finalizeEngine is the GC fallback path when the caller forgot to // call Shutdown. Takes the write lock so it can't race with an // explicit Shutdown that's already running. -func finalizeManager(m *PluginManager) { +func finalizeEngine(m *PolicyEngine) { m.mu.Lock() defer m.mu.Unlock() if m.handle != nil { @@ -155,44 +155,47 @@ func finalizeManager(m *PluginManager) { } } -// NewPluginManager creates a manager from a YAML config string. -// Built-in Rust plugin factories are registered automatically. -func NewPluginManager(yaml string) (*PluginManager, error) { +// NewPolicyEngine creates an engine and loads a YAML config string into +// it in one step. No builtin factories are registered on this path, so +// the config can name only plugins registered in Rust before the load, +// and EnableAPL cannot help (the config is already loaded). Use +// NewPolicyEngineDefault, EnableAPL and LoadConfig for builtins. +func NewPolicyEngine(yaml string) (*PolicyEngine, error) { cYaml := C.CString(yaml) defer C.free(unsafe.Pointer(cYaml)) - handle := C.cpex_manager_new(cYaml, C.int(len(yaml))) + handle := C.cpex_engine_new(cYaml, C.int(len(yaml))) if handle == nil { - return nil, errors.New("cpex: failed to create plugin manager from config") + return nil, errors.New("cpex: failed to create policy engine from config") } - mgr := &PluginManager{handle: handle} - runtime.SetFinalizer(mgr, finalizeManager) - return mgr, nil + engine := &PolicyEngine{handle: handle} + runtime.SetFinalizer(engine, finalizeEngine) + return engine, nil } -// NewPluginManagerDefault creates a manager with default config. -// Useful when registering plugins programmatically. -func NewPluginManagerDefault() (*PluginManager, error) { - handle := C.cpex_manager_new_default() +// NewPolicyEngineDefault creates an engine with default config. +// Follow it with EnableAPL and/or RegisterFactories, then LoadConfig. +func NewPolicyEngineDefault() (*PolicyEngine, error) { + handle := C.cpex_engine_new_default() if handle == nil { - return nil, errors.New("cpex: failed to create default plugin manager") + return nil, errors.New("cpex: failed to create default policy engine") } - mgr := &PluginManager{handle: handle} - runtime.SetFinalizer(mgr, finalizeManager) - return mgr, nil + engine := &PolicyEngine{handle: handle} + runtime.SetFinalizer(engine, finalizeEngine) + return engine, nil } // FactoryRegistrar is a function that registers plugin factories on the -// manager's internal handle. The handle is an opaque C pointer — callers +// engine's internal handle. The handle is an opaque C pointer — callers // pass it to their own extern C registration function. type FactoryRegistrar func(handle unsafe.Pointer) error -// RegisterFactories calls fn with the manager's internal C handle, +// RegisterFactories calls fn with the engine's internal C handle, // allowing callers to register plugin factories via their own FFI. // Must be called before LoadConfig. -func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error { +func (m *PolicyEngine) RegisterFactories(fn FactoryRegistrar) error { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -201,13 +204,13 @@ func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error { return fn(unsafe.Pointer(m.handle)) } -// LoadConfig loads a YAML config string into the manager. +// LoadConfig loads a YAML config string into the engine. // Factories must be registered before calling this method. // // On failure, the returned error wraps one of the typed sentinels // (ErrCpexInvalidHandle, ErrCpexInvalidInput, ErrCpexParse, // ErrCpexPipeline, ErrCpexPanic). Use `errors.Is` to classify. -func (m *PluginManager) LoadConfig(yaml string) error { +func (m *PolicyEngine) LoadConfig(yaml string) error { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -226,7 +229,7 @@ func (m *PluginManager) LoadConfig(yaml string) error { // // On failure, the returned error wraps one of the typed sentinels // (ErrCpexInvalidHandle, ErrCpexPipeline, ErrCpexTimeout, ErrCpexPanic). -func (m *PluginManager) Initialize() error { +func (m *PolicyEngine) Initialize() error { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -238,12 +241,12 @@ func (m *PluginManager) Initialize() error { } // Shutdown gracefully shuts down all plugins and releases resources. -// After this call, the manager is invalid and must not be used. +// After this call, the engine is invalid and must not be used. // // Takes the write lock to ensure no in-flight cgo call is racing with // the destruction of the C handle. Also clears the GC finalizer so // the finalizer can't fire later and double-free. -func (m *PluginManager) Shutdown() { +func (m *PolicyEngine) Shutdown() { m.mu.Lock() defer m.mu.Unlock() if m.handle == nil { @@ -258,7 +261,7 @@ func (m *PluginManager) Shutdown() { // HasHooksFor returns true if any plugins are registered for the hook. // No serialization — just a hash lookup across the FFI boundary. -func (m *PluginManager) HasHooksFor(hookName string) bool { +func (m *PolicyEngine) HasHooksFor(hookName string) bool { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -270,7 +273,7 @@ func (m *PluginManager) HasHooksFor(hookName string) bool { } // PluginCount returns the number of registered plugins. -func (m *PluginManager) PluginCount() int { +func (m *PolicyEngine) PluginCount() int { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -280,9 +283,9 @@ func (m *PluginManager) PluginCount() int { } // IsInitialized reports whether Initialize has been called and Shutdown -// has not. Useful for agent control loops that may inspect manager +// has not. Useful for agent control loops that may inspect engine // state before deciding whether to dispatch. -func (m *PluginManager) IsInitialized() bool { +func (m *PolicyEngine) IsInitialized() bool { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -293,7 +296,7 @@ func (m *PluginManager) IsInitialized() bool { // PluginNames returns the names of all registered plugins. Order is // not stable across calls — the underlying registry uses a HashMap. -func (m *PluginManager) PluginNames() ([]string, error) { +func (m *PolicyEngine) PluginNames() ([]string, error) { m.mu.RLock() defer m.mu.RUnlock() if m.handle == nil { @@ -321,7 +324,7 @@ func (m *PluginManager) PluginNames() ([]string, error) { // Payload and extensions are serialized to MessagePack internally. // The ContextTable is an opaque handle — pass nil on the first call, // then thread result's ContextTable into subsequent calls. -func (m *PluginManager) InvokeByName( +func (m *PolicyEngine) InvokeByName( hookName string, payloadType uint8, payload any, @@ -419,10 +422,10 @@ func (m *PluginManager) InvokeByName( ct.Close() }) - // Hold *PluginManager (not the raw C handle) so Wait() can check - // mgr.handle != nil under the manager's mutex — preventing UAF + // Hold *PolicyEngine (not the raw C handle) so Wait() can check + // engine.handle != nil under the engine's mutex — preventing UAF // if Shutdown is called between this invoke and Wait(). - bg := &BackgroundTasks{handle: bgOut, mgr: m} + bg := &BackgroundTasks{handle: bgOut, engine: m} return &result, resultCT, bg, nil } @@ -435,11 +438,11 @@ func (m *PluginManager) InvokeByName( // token, which a separate resolve-then-invoke pair loses. // // `identity` carries the request headers the resolvers read (X-User-Token, -// Authorization, …). When the manager has no identity.resolve hook +// Authorization, …). When the engine has no identity.resolve hook // registered, this degrades to a plain InvokeByName(hookName, ...). // Otherwise the semantics (ContextTable threading, ownership, result // shape) match InvokeByName. -func (m *PluginManager) InvokeResolved( +func (m *PolicyEngine) InvokeResolved( identity IdentityPayload, hookName string, payloadType uint8, @@ -472,9 +475,13 @@ func (m *PluginManager) InvokeResolved( cHookName := C.CString(hookName) defer C.free(unsafe.Pointer(cHookName)) + // cpex_invoke_resolved takes ownership of the context table on entry, + // on every return path, like cpex_invoke. Clear the caller's handle + // now so an error return cannot leave it pointing at freed memory. var ctHandle C.CpexContextTable if contextTable != nil { ctHandle = contextTable.handle + contextTable.handle = nil } var resultPtr *C.uint8_t @@ -514,11 +521,6 @@ func (m *PluginManager) InvokeResolved( return nil, nil, nil, errorFromRC(int(rc), "InvokeResolved") } - // Rust consumed ctHandle and produced ctOut — safe to nil our ref. - if contextTable != nil { - contextTable.handle = nil - } - resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) @@ -532,7 +534,7 @@ func (m *PluginManager) InvokeResolved( ct.Close() }) - bg := &BackgroundTasks{handle: bgOut, mgr: m} + bg := &BackgroundTasks{handle: bgOut, engine: m} return &result, resultCT, bg, nil } @@ -543,7 +545,7 @@ func (m *PluginManager) InvokeResolved( // Example: // // result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( -// mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, +// engine, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, // payload, ext, nil, // ) // if !result.IsDenied() && result.PayloadModified { @@ -554,7 +556,7 @@ func (m *PluginManager) InvokeResolved( // final payload whether or not a plugin touched it. Test PayloadModified // to learn whether a plugin actually changed it. func Invoke[P any]( - m *PluginManager, + m *PolicyEngine, hookName string, payloadType uint8, payload P, @@ -599,7 +601,7 @@ func Invoke[P any]( // Wait blocks until all background tasks complete. // Returns structured errors from any tasks that failed (panicked, // errored, or timed out), plus an error if the underlying FFI call -// failed (e.g., the manager was already shutdown). On FFI failure the +// failed (e.g., the engine was already shutdown). On FFI failure the // returned slice is nil. // // Each PluginError carries the failing plugin's name, a message, an @@ -607,33 +609,33 @@ func Invoke[P any]( // error code (JSON-RPC / HTTP) — enough for an agent to classify // failures without parsing strings. // -// Holds the manager's read lock for the duration of the cgo call so +// Holds the engine's read lock for the duration of the cgo call so // the C handle can't be freed by a concurrent Shutdown. func (bg *BackgroundTasks) Wait() ([]PluginError, error) { if bg.handle == nil { return nil, nil } - if bg.mgr == nil { + if bg.engine == nil { return nil, fmt.Errorf("BackgroundTasks.Wait: %w", ErrCpexInvalidHandle) } - bg.mgr.mu.RLock() - defer bg.mgr.mu.RUnlock() - if bg.mgr.handle == nil { + bg.engine.mu.RLock() + defer bg.engine.mu.RUnlock() + if bg.engine.handle == nil { // Rust still owns the BackgroundTasks box. The Rust-side // `cpex_wait_background` consumes it even on the - // null-mgr path (P2 #11 fix), so we must not call into - // Rust without a live manager — the box would leak. + // null-engine path (P2 #11 fix), so we must not call into + // Rust without a live engine — the box would leak. // Best we can do is null our handle so the caller doesn't // try again, and report the error. bg.handle = nil - return nil, fmt.Errorf("BackgroundTasks.Wait: %w (manager shutdown; background tasks abandoned)", ErrCpexInvalidHandle) + return nil, fmt.Errorf("BackgroundTasks.Wait: %w (engine shutdown; background tasks abandoned)", ErrCpexInvalidHandle) } var errorsPtr *C.uint8_t var errorsLen C.int - rc := C.cpex_wait_background(bg.mgr.handle, bg.handle, &errorsPtr, &errorsLen) + rc := C.cpex_wait_background(bg.engine.handle, bg.handle, &errorsPtr, &errorsLen) bg.handle = nil // consumed by Rust regardless of rc (per P2 #11 fix) if rc != 0 { diff --git a/go/cpex/manager_test.go b/go/cpex/engine_test.go similarity index 79% rename from go/cpex/manager_test.go rename to go/cpex/engine_test.go index 747ad8a3..86442276 100644 --- a/go/cpex/manager_test.go +++ b/go/cpex/engine_test.go @@ -1,4 +1,4 @@ -// Location: ./go/cpex/manager_test.go +// Location: ./go/cpex/engine_test.go // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -21,57 +21,88 @@ import ( "github.com/vmihailenco/msgpack/v5" ) -func TestNewPluginManagerDefault(t *testing.T) { - mgr, err := NewPluginManagerDefault() +func TestNewPolicyEngineDefault(t *testing.T) { + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if mgr.PluginCount() != 0 { - t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + if eng.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", eng.PluginCount()) } - if mgr.HasHooksFor("test_hook") { + if eng.HasHooksFor("test_hook") { t.Error("expected no hooks registered") } } -func TestNewPluginManagerFromYAML(t *testing.T) { +func TestNewPolicyEngineFromYAML(t *testing.T) { yaml := ` -plugin_settings: +engine_settings: plugin_timeout: 30 ` - mgr, err := NewPluginManager(yaml) + eng, err := NewPolicyEngine(yaml) if err != nil { - t.Fatalf("NewPluginManager failed: %v", err) + t.Fatalf("NewPolicyEngine failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } - if mgr.PluginCount() != 0 { - t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + if eng.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", eng.PluginCount()) } } -func TestNewPluginManagerInvalidYAML(t *testing.T) { - _, err := NewPluginManager("not: [valid: yaml: {{}") +func TestNewPolicyEngineInvalidYAML(t *testing.T) { + _, err := NewPolicyEngine("not: [valid: yaml: {{}") if err == nil { t.Error("expected error for invalid YAML") } } +func TestNewPolicyEngineRejectsAPLRules(t *testing.T) { + yaml := "routes:\n - tool: lookup\n authorization:\n pre_invocation: [\"deny\"]\n" + eng, err := NewPolicyEngine(yaml) + if err == nil { + eng.Shutdown() + t.Fatal("APL rules must not be silently discarded") + } +} + +func TestIdentityResolveRejectsCallerSuppliedOutputs(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatal(err) + } + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { + t.Fatal(err) + } + for name, payload := range map[string]IdentityPayload{ + "subject": {Source: TokenSourceBearer, Subject: &SubjectExtension{ID: "forged"}}, + "raw_claims": {Source: TokenSourceBearer, RawClaims: map[string]any{"sub": "forged"}}, + } { + t.Run(name, func(t *testing.T) { + _, _, _, err := eng.InvokeByName(HookIdentityResolve, PayloadIdentity, payload, nil, nil) + if !errors.Is(err, ErrCpexInvalidInput) { + t.Fatalf("expected ErrCpexInvalidInput, got %v", err) + } + }) + } +} + func TestInvokeByNameNoPlugins(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } @@ -88,7 +119,7 @@ func TestInvokeByNameNoPlugins(t *testing.T) { }, } - result, ctxTable, bg, err := mgr.InvokeByName("test_hook", PayloadGeneric, payload, ext, nil) + result, ctxTable, bg, err := eng.InvokeByName("test_hook", PayloadGeneric, payload, ext, nil) if err != nil { t.Fatalf("InvokeByName failed: %v", err) } @@ -105,13 +136,13 @@ func TestInvokeByNameNoPlugins(t *testing.T) { } func TestInvokeByNameWithContextTableThreading(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } @@ -119,7 +150,7 @@ func TestInvokeByNameWithContextTableThreading(t *testing.T) { ext := &Extensions{} // First invocation — nil context table - result1, ctxTable1, bg1, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, ext, nil) + result1, ctxTable1, bg1, err := eng.InvokeByName("hook1", PayloadGeneric, payload, ext, nil) if err != nil { t.Fatalf("first invoke failed: %v", err) } @@ -130,7 +161,7 @@ func TestInvokeByNameWithContextTableThreading(t *testing.T) { } // Second invocation — thread context table from first - result2, ctxTable2, bg2, err := mgr.InvokeByName("hook2", PayloadGeneric, payload, ext, ctxTable1) + result2, ctxTable2, bg2, err := eng.InvokeByName("hook2", PayloadGeneric, payload, ext, ctxTable1) if err != nil { t.Fatalf("second invoke failed: %v", err) } @@ -144,19 +175,19 @@ func TestInvokeByNameWithContextTableThreading(t *testing.T) { } func TestBackgroundTasksWait(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } payload := map[string]any{"test": true} - result, ctxTable, bg, err := mgr.InvokeByName("test", PayloadGeneric, payload, nil, nil) + result, ctxTable, bg, err := eng.InvokeByName("test", PayloadGeneric, payload, nil, nil) if err != nil { t.Fatalf("invoke failed: %v", err) } @@ -174,15 +205,15 @@ func TestBackgroundTasksWait(t *testing.T) { } } -// Concurrent goroutines invoking against a single manager must be safe +// Concurrent goroutines invoking against a single engine must be safe // (validates the P0 #1 aliased-&mut fix and the Pass 2 RWMutex). Run // under -race to surface any data races on the handle. func TestConcurrentInvokesAreSafe(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() const goroutines = 32 const callsPerGoroutine = 16 @@ -194,7 +225,7 @@ func TestConcurrentInvokesAreSafe(t *testing.T) { defer wg.Done() for j := 0; j < callsPerGoroutine; j++ { payload := map[string]any{"i": i, "j": j} - _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + _, ct, bg, err := eng.InvokeByName("noop", PayloadGeneric, payload, nil, nil) if err != nil { t.Errorf("invoke failed: %v", err) return @@ -213,11 +244,11 @@ func TestConcurrentInvokesAreSafe(t *testing.T) { // Calling Shutdown while goroutines are mid-invoke must not double-free // or panic. After Shutdown, in-flight invokes should observe that the -// manager is shutdown and return an error gracefully. +// engine is shutdown and return an error gracefully. func TestShutdownDuringInvokesIsSafe(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } // Spawn workers that invoke in a tight loop until they observe shutdown. @@ -234,7 +265,7 @@ func TestShutdownDuringInvokesIsSafe(t *testing.T) { default: } payload := map[string]any{"x": 1} - _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + _, ct, bg, err := eng.InvokeByName("noop", PayloadGeneric, payload, nil, nil) if err != nil { // Expected once Shutdown lands; just stop. return @@ -250,36 +281,36 @@ func TestShutdownDuringInvokesIsSafe(t *testing.T) { } // Let them spin for a moment, then shutdown. - mgr.Shutdown() + eng.Shutdown() close(stop) wg.Wait() // Second Shutdown must be a no-op (P1 #4 fix — finalizer cleared, // double-call returns immediately). - mgr.Shutdown() + eng.Shutdown() } -// BackgroundTasks.Wait() called after the manager has been Shutdown +// BackgroundTasks.Wait() called after the engine has been Shutdown // must return ErrCpexInvalidHandle without crashing or reading // uninitialized output pointers. Direct regression for the P1 #3 + -// P1 #5 fix path where bg holds a *PluginManager and checks handle -// nullness under the manager's RWMutex. +// P1 #5 fix path where bg holds a *PolicyEngine and checks handle +// nullness under the engine's RWMutex. func TestBackgroundTasksWaitAfterShutdownReturnsTypedError(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } - _, _, bg, err := mgr.InvokeByName("noop", PayloadGeneric, map[string]any{}, nil, nil) + _, _, bg, err := eng.InvokeByName("noop", PayloadGeneric, map[string]any{}, nil, nil) if err != nil { t.Fatalf("InvokeByName failed: %v", err) } - // Tear the manager down BEFORE waiting on the background tasks. - mgr.Shutdown() + // Tear the engine down BEFORE waiting on the background tasks. + eng.Shutdown() // Wait must observe the shutdown handle and return a typed error // — not panic, not segfault on a stale C pointer, not silently @@ -298,43 +329,43 @@ func TestBackgroundTasksWaitAfterShutdownReturnsTypedError(t *testing.T) { // rejects unknown values with RC_PARSE_ERROR, which the Go side // classifies via errorFromRC. func TestInvokeUnknownPayloadTypeReturnsParseError(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() - if err := mgr.Initialize(); err != nil { + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } const unknownType uint8 = 99 // not in the FFI payload registry - _, _, _, err = mgr.InvokeByName("test_hook", unknownType, map[string]any{}, nil, nil) + _, _, _, err = eng.InvokeByName("test_hook", unknownType, map[string]any{}, nil, nil) if !errors.Is(err, ErrCpexParse) { t.Errorf("expected ErrCpexParse for unknown payload_type, got %v", err) } } -// IsInitialized reports manager lifecycle accurately: false until +// IsInitialized reports engine lifecycle accurately: false until // Initialize is called, true after, false again after Shutdown. // Validates agent-native gap #4 — introspection FFI. func TestIsInitializedTracksLifecycle(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - if mgr.IsInitialized() { + if eng.IsInitialized() { t.Error("expected IsInitialized=false before Initialize") } - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } - if !mgr.IsInitialized() { + if !eng.IsInitialized() { t.Error("expected IsInitialized=true after Initialize") } - mgr.Shutdown() - if mgr.IsInitialized() { + eng.Shutdown() + if eng.IsInitialized() { t.Error("expected IsInitialized=false after Shutdown") } } @@ -342,34 +373,34 @@ func TestIsInitializedTracksLifecycle(t *testing.T) { // PluginNames returns the names of plugins registered via YAML config. // Validates agent-native gap #4 — introspection FFI. func TestPluginNamesEmptyByDefault(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - names, err := mgr.PluginNames() + names, err := eng.PluginNames() if err != nil { t.Fatalf("PluginNames failed: %v", err) } if len(names) != 0 { - t.Errorf("expected empty plugin names on a default manager, got %v", names) + t.Errorf("expected empty plugin names on a default engine, got %v", names) } } // BackgroundTasks.Wait returns []PluginError (structured) — gap #3. // On a no-plugin invoke the slice is empty but non-nil-shaped. func TestBackgroundTasksWaitReturnsStructuredErrors(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() - if err := mgr.Initialize(); err != nil { + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } - _, _, bg, err := mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + _, _, bg, err := eng.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) if err != nil { t.Fatalf("Invoke failed: %v", err) } @@ -379,43 +410,43 @@ func TestBackgroundTasksWaitReturnsStructuredErrors(t *testing.T) { t.Errorf("Wait failed: %v", err) } // errs is []PluginError — typed at compile time. Empty for a - // no-plugin manager, but the structured type is what we wanted. + // no-plugin engine, but the structured type is what we wanted. if len(errs) != 0 { t.Errorf("expected no errors on no-plugin invoke, got %d: %v", len(errs), errs) } } -// Operations on a shutdown manager must return an error wrapping +// Operations on a shutdown engine must return an error wrapping // ErrCpexInvalidHandle so callers can classify with errors.Is. // Validates the P2 #18 typed-error mapping end-to-end. func TestOperationsAfterShutdownReturnTypedError(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - mgr.Shutdown() + eng.Shutdown() - if err := mgr.Initialize(); !errors.Is(err, ErrCpexInvalidHandle) { + if err := eng.Initialize(); !errors.Is(err, ErrCpexInvalidHandle) { t.Errorf("Initialize after shutdown: expected ErrCpexInvalidHandle, got %v", err) } - if err := mgr.LoadConfig("plugin_settings: {}"); !errors.Is(err, ErrCpexInvalidHandle) { + if err := eng.LoadConfig("engine_settings: {}"); !errors.Is(err, ErrCpexInvalidHandle) { t.Errorf("LoadConfig after shutdown: expected ErrCpexInvalidHandle, got %v", err) } - _, _, _, err = mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + _, _, _, err = eng.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) if !errors.Is(err, ErrCpexInvalidHandle) { t.Errorf("InvokeByName after shutdown: expected ErrCpexInvalidHandle, got %v", err) } } -func TestPluginManagerDoubleShutdown(t *testing.T) { - mgr, err := NewPluginManagerDefault() +func TestPolicyEngineDoubleShutdown(t *testing.T) { + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - mgr.Shutdown() + eng.Shutdown() // Second shutdown should not panic - mgr.Shutdown() + eng.Shutdown() } func TestContextTableDoubleClose(t *testing.T) { @@ -458,9 +489,9 @@ func TestExtensionsSerialization(t *testing.T) { Security: &SecurityExtension{ Labels: []string{"PII"}, Classification: "confidential", - Agent: &AgentIdentity{ + ThisWorkload: &WorkloadIdentity{ ClientID: "hr-agent", - WorkloadID: "spiffe://corp.com/hr-agent", + SpiffeID: "spiffe://corp.com/hr-agent", TrustDomain: "corp.com", }, }, @@ -489,13 +520,13 @@ func msgpackMarshal(v any) ([]byte, error) { // --------------------------------------------------------------------------- func TestInvokeTypedGenericPayload(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } @@ -505,7 +536,7 @@ func TestInvokeTypedGenericPayload(t *testing.T) { } result, ct, bg, err := Invoke[map[string]any]( - mgr, "test_hook", PayloadGeneric, payload, &Extensions{}, nil, + eng, "test_hook", PayloadGeneric, payload, &Extensions{}, nil, ) if err != nil { t.Fatalf("Invoke failed: %v", err) @@ -523,13 +554,13 @@ func TestInvokeTypedGenericPayload(t *testing.T) { } func TestInvokeTypedCMFPayload(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } @@ -553,7 +584,7 @@ func TestInvokeTypedCMFPayload(t *testing.T) { } result, ct, bg, err := Invoke[MessagePayload]( - mgr, "cmf.tool_pre_invoke", PayloadCMFMessage, msg, ext, nil, + eng, "cmf.tool_pre_invoke", PayloadCMFMessage, msg, ext, nil, ) if err != nil { t.Fatalf("Invoke failed: %v", err) @@ -567,13 +598,13 @@ func TestInvokeTypedCMFPayload(t *testing.T) { } func TestInvokeTypedContextThreading(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } @@ -581,7 +612,7 @@ func TestInvokeTypedContextThreading(t *testing.T) { // First call — nil context table r1, ct1, bg1, err := Invoke[map[string]any]( - mgr, "hook1", PayloadGeneric, payload, &Extensions{}, nil, + eng, "hook1", PayloadGeneric, payload, &Extensions{}, nil, ) if err != nil { t.Fatalf("first invoke failed: %v", err) @@ -594,7 +625,7 @@ func TestInvokeTypedContextThreading(t *testing.T) { // Second call — thread context table r2, ct2, bg2, err := Invoke[map[string]any]( - mgr, "hook2", PayloadGeneric, payload, &Extensions{}, ct1, + eng, "hook2", PayloadGeneric, payload, &Extensions{}, ct1, ) if err != nil { t.Fatalf("second invoke failed: %v", err) @@ -1140,35 +1171,35 @@ func TestMessagePayloadSerialization(t *testing.T) { } } -func TestLoadConfigOnDefaultManager(t *testing.T) { - mgr, err := NewPluginManagerDefault() +func TestLoadConfigOnDefaultEngine(t *testing.T) { + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() // LoadConfig with valid YAML (no plugins, just settings) - err = mgr.LoadConfig(` -plugin_settings: + err = eng.LoadConfig(` +engine_settings: plugin_timeout: 30 `) if err != nil { t.Fatalf("LoadConfig failed: %v", err) } - if err := mgr.Initialize(); err != nil { + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } } func TestLoadConfigInvalidYAML(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() + defer eng.Shutdown() - err = mgr.LoadConfig("not: [valid: yaml: {{}") + err = eng.LoadConfig("not: [valid: yaml: {{}") if err == nil { t.Error("expected error for invalid YAML") } @@ -1195,12 +1226,12 @@ func TestLoadConfigInvalidYAML(t *testing.T) { // 4. Calls Close() on the original ContextTable — must NOT crash. // Pre-fix this was a UAF (free of already-freed memory). func TestInvokeByNameErrorDoesNotUAFContextTable(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - defer mgr.Shutdown() - if err := mgr.Initialize(); err != nil { + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { t.Fatalf("Initialize failed: %v", err) } @@ -1208,7 +1239,7 @@ func TestInvokeByNameErrorDoesNotUAFContextTable(t *testing.T) { // ContextTable. Calling Close() on this pre-fix would have // been the second free. payload := map[string]any{"tool_name": "test"} - _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + _, ctxTable, bg, err := eng.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) if err != nil { t.Fatalf("first invoke failed: %v", err) } @@ -1223,7 +1254,7 @@ func TestInvokeByNameErrorDoesNotUAFContextTable(t *testing.T) { // that error path is now POST-consumption of the input // context_table. const unknownPayloadType uint8 = 99 - _, _, _, err = mgr.InvokeByName("hook2", unknownPayloadType, payload, &Extensions{}, ctxTable) + _, _, _, err = eng.InvokeByName("hook2", unknownPayloadType, payload, &Extensions{}, ctxTable) if err == nil { t.Fatal("expected error from invoke with unknown payload_type") } @@ -1243,37 +1274,37 @@ func TestInvokeByNameErrorDoesNotUAFContextTable(t *testing.T) { } // TestInvokeByNameConsumesContextTableOnRcError pins the other half -// of the P0-1 contract — even when the manager rejects the call with +// of the P0-1 contract — even when the engine rejects the call with // a validation-class error (here: shutdown after first invoke), the // caller's ContextTable handle is nil'd unconditionally. // // Verifies: no leak of the input Box when Rust never gets to write // the output; Close() on the input is a safe no-op. func TestInvokeByNameConsumesContextTableEvenOnShutdownPath(t *testing.T) { - mgr, err := NewPluginManagerDefault() + eng, err := NewPolicyEngineDefault() if err != nil { - t.Fatalf("NewPluginManagerDefault failed: %v", err) + t.Fatalf("NewPolicyEngineDefault failed: %v", err) } - if err := mgr.Initialize(); err != nil { - mgr.Shutdown() + if err := eng.Initialize(); err != nil { + eng.Shutdown() t.Fatalf("Initialize failed: %v", err) } payload := map[string]any{"tool_name": "test"} - _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + _, ctxTable, bg, err := eng.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) if err != nil { - mgr.Shutdown() + eng.Shutdown() t.Fatalf("first invoke failed: %v", err) } bg.Close() - // Shut down the manager — Go-side short-circuit will return + // Shut down the engine — Go-side short-circuit will return // ErrCpexInvalidHandle WITHOUT calling cpex_invoke. The Go // wrapper hasn't touched ctxTable yet in this case (early // return at m.handle == nil), so ctxTable.handle remains live. - mgr.Shutdown() + eng.Shutdown() - _, _, _, err = mgr.InvokeByName("hook2", PayloadGeneric, payload, &Extensions{}, ctxTable) + _, _, _, err = eng.InvokeByName("hook2", PayloadGeneric, payload, &Extensions{}, ctxTable) if !errors.Is(err, ErrCpexInvalidHandle) { t.Errorf("expected ErrCpexInvalidHandle after shutdown, got %v", err) } @@ -1283,3 +1314,37 @@ func TestInvokeByNameConsumesContextTableEvenOnShutdownPath(t *testing.T) { // thing for callers to do. ctxTable.Close() } + +// InvokeResolved shares cpex_invoke's ownership contract: the input +// ContextTable is consumed on every return path, so an error return +// must leave the caller's handle nil and Close() a no-op. +func TestInvokeResolvedErrorDoesNotUAFContextTable(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + _, ctxTable, bg, err := eng.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg.Close() + if ctxTable == nil || ctxTable.handle == nil { + t.Fatal("expected a non-nil ContextTable from the first invoke") + } + + const unknownPayloadType uint8 = 99 + _, _, _, err = eng.InvokeResolved(IdentityPayload{}, "hook2", unknownPayloadType, payload, &Extensions{}, ctxTable) + if !errors.Is(err, ErrCpexParse) { + t.Fatalf("expected ErrCpexParse from an unknown payload type, got %v", err) + } + if ctxTable.handle != nil { + t.Errorf("input ContextTable.handle should be nil after an InvokeResolved error; got %p", ctxTable.handle) + } + ctxTable.Close() +} diff --git a/go/cpex/errors.go b/go/cpex/errors.go index 4a193ae6..c3695770 100644 --- a/go/cpex/errors.go +++ b/go/cpex/errors.go @@ -19,12 +19,12 @@ import ( ) // Sentinel errors returned by the FFI wrapper. Compare with `errors.Is`. -// All errors returned from PluginManager / BackgroundTasks methods that +// All errors returned from PolicyEngine / BackgroundTasks methods that // originated in a non-zero FFI return code wrap one of these. var ( - // ErrCpexInvalidHandle: the manager handle is null or has been - // shutdown. Trying to use a manager after Shutdown produces this. - ErrCpexInvalidHandle = errors.New("cpex: invalid handle (manager null or shutdown)") + // ErrCpexInvalidHandle: the engine handle is null or has been + // shutdown. Trying to use an engine after Shutdown produces this. + ErrCpexInvalidHandle = errors.New("cpex: invalid handle (engine null or shutdown)") // ErrCpexInvalidInput: caller-supplied input was malformed — bad // UTF-8, null pointer where data was required, oversized buffer, @@ -53,10 +53,11 @@ var ( // disable the offending plugin first. ErrCpexTimeout = errors.New("cpex: wall-clock timeout exceeded") - // ErrCpexPanic: a plugin panicked across the FFI boundary; the - // panic was caught (preventing UB / process abort) but the - // invocation is lost. Same plugin will likely panic again. - ErrCpexPanic = errors.New("cpex: plugin panicked at FFI boundary") + // ErrCpexPanic: code in the FFI layer panicked; the panic was + // caught (preventing UB / process abort) but the call is lost. + // A plugin panic does not land here: the engine contains it and + // returns a denied result with violation code plugin_panic. + ErrCpexPanic = errors.New("cpex: panic at FFI boundary") ) // errorFromRC maps an FFI return code to a typed error. `op` is included diff --git a/go/cpex/extensions_test.go b/go/cpex/extensions_test.go new file mode 100644 index 00000000..74e59dba --- /dev/null +++ b/go/cpex/extensions_test.go @@ -0,0 +1,385 @@ +// Location: ./go/cpex/extensions_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// Wire-shape tests for the Go mirrors of the Rust extension and payload +// types: values Rust emits must decode, and values Go emits must reach +// Rust intact. The FFI tests need the staticlib: +// +// cargo build --release -p cpex-ffi + +package cpex + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/vmihailenco/msgpack/v5" +) + +// nonStringClaims are the claim shapes an IdP commonly sends beyond +// plain strings. +func nonStringClaims() map[string]any { + return map[string]any{ + "email": "alice@corp.com", + "email_verified": true, + "auth_time": int64(1700000000), + "ratio": 0.75, + "realm_access": map[string]any{"roles": []any{"admin", "hr"}, "level": int64(3)}, + "amr": []any{"pwd", "mfa"}, + } +} + +// assertClaims checks the decoded claims against nonStringClaims. +// Numbers are compared through float64 because a JSON round trip and +// the msgpack decoder pick different integer widths. +func assertClaims(t *testing.T, label string, got map[string]any) { + t.Helper() + if got == nil { + t.Fatalf("%s: claims are nil", label) + } + if v, ok := got["email"].(string); !ok || v != "alice@corp.com" { + t.Errorf("%s: email = %#v", label, got["email"]) + } + if v, ok := got["email_verified"].(bool); !ok || !v { + t.Errorf("%s: email_verified = %#v", label, got["email_verified"]) + } + if v, ok := asFloat(got["auth_time"]); !ok || v != 1700000000 { + t.Errorf("%s: auth_time = %#v", label, got["auth_time"]) + } + if v, ok := asFloat(got["ratio"]); !ok || v != 0.75 { + t.Errorf("%s: ratio = %#v", label, got["ratio"]) + } + realm, ok := got["realm_access"].(map[string]any) + if !ok { + t.Fatalf("%s: realm_access = %#v", label, got["realm_access"]) + } + if !reflect.DeepEqual(realm["roles"], []any{"admin", "hr"}) { + t.Errorf("%s: realm_access.roles = %#v", label, realm["roles"]) + } + if v, ok := asFloat(realm["level"]); !ok || v != 3 { + t.Errorf("%s: realm_access.level = %#v", label, realm["level"]) + } + if !reflect.DeepEqual(got["amr"], []any{"pwd", "mfa"}) { + t.Errorf("%s: amr = %#v", label, got["amr"]) + } +} + +func asFloat(v any) (float64, bool) { + switch n := v.(type) { + case int8: + return float64(n), true + case int16: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint8: + return float64(n), true + case uint16: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + case float32: + return float64(n), true + case float64: + return n, true + } + return 0, false +} + +// TestSubjectClaimsDecodeNonStringValues decodes the shape Rust emits +// for a subject whose claims are not all strings, both as an +// IdentityPayload and inside Extensions. +func TestSubjectClaimsDecodeNonStringValues(t *testing.T) { + subject := map[string]any{ + "id": "alice", + "roles": []string{"hr"}, + "claims": nonStringClaims(), + } + + idBytes, err := msgpack.Marshal(map[string]any{ + "source": "bearer", + "subject": subject, + "raw_claims": nonStringClaims(), + }) + if err != nil { + t.Fatalf("marshal identity: %v", err) + } + var idp IdentityPayload + if err := msgpack.Unmarshal(idBytes, &idp); err != nil { + t.Fatalf("decode IdentityPayload: %v", err) + } + if idp.Subject == nil { + t.Fatal("IdentityPayload.Subject is nil") + } + assertClaims(t, "IdentityPayload.Subject.Claims", idp.Subject.Claims) + assertClaims(t, "IdentityPayload.RawClaims", idp.RawClaims) + + extBytes, err := msgpack.Marshal(map[string]any{ + "security": map[string]any{ + "subject": subject, + "client": map[string]any{"client_id": "web", "claims": nonStringClaims()}, + }, + }) + if err != nil { + t.Fatalf("marshal extensions: %v", err) + } + var ext Extensions + if err := msgpack.Unmarshal(extBytes, &ext); err != nil { + t.Fatalf("decode Extensions: %v", err) + } + if ext.Security == nil || ext.Security.Subject == nil || ext.Security.Client == nil { + t.Fatalf("security slots missing: %+v", ext.Security) + } + assertClaims(t, "Extensions.Security.Subject.Claims", ext.Security.Subject.Claims) + assertClaims(t, "Extensions.Security.Client.Claims", ext.Security.Client.Claims) +} + +// mintJWTWithClaims signs an HS256 token for sub with roles and the +// extra claims merged in. +func mintJWTWithClaims(t *testing.T, sub string, roles []string, extra map[string]any) string { + t.Helper() + now := time.Now() + claims := map[string]any{ + "iss": accIssuer, + "aud": accAudience, + "sub": sub, + "iat": now.Add(-5 * time.Second).Unix(), + "exp": now.Add(10 * time.Minute).Unix(), + "roles": roles, + } + for k, v := range extra { + claims[k] = v + } + header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT", "kid": accKID}) + body, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + signing := b64url(header) + "." + b64url(body) + mac := hmac.New(sha256.New, accSecret) + mac.Write([]byte(signing)) + return signing + "." + b64url(mac.Sum(nil)) +} + +// TestAcceptanceJWTNonStringClaimsRoundTrip resolves a JWT whose claims +// include a bool, numbers, a nested map and an array, and reads them +// back from both the resolved IdentityPayload and the extensions of an +// InvokeResolved call. +func TestAcceptanceJWTNonStringClaimsRoundTrip(t *testing.T) { + idp := newMockIdP(t) + eng := accEngine(t, idpPlugin(idp.srv.URL+"/jwks", true)+accIdentityRoutes, WithPrivateDestinations()) + token := mintJWTWithClaims(t, "alice", []string{"hr"}, nonStringClaims()) + headers := map[string]string{"authorization": "Bearer " + token} + meta := &MetaExtension{EntityType: "tool", EntityName: "get_compensation"} + + t.Run("identity.resolve payload", func(t *testing.T) { + res, ct, bg, err := eng.InvokeByName(HookIdentityResolve, PayloadIdentity, + NewIdentityPayload(TokenSourceBearer, headers), &Extensions{Meta: meta}, nil) + if err != nil { + t.Fatalf("InvokeByName(identity.resolve): %v", err) + } + defer bg.Close() + defer ct.Close() + wantAllowed(t, "identity.resolve", res) + resolved, err := DeserializePayload[IdentityPayload](res) + if err != nil { + t.Fatalf("decode resolved IdentityPayload: %v", err) + } + if resolved == nil || resolved.Subject == nil { + t.Fatalf("no resolved subject: %+v", resolved) + } + if resolved.Subject.ID != "alice" { + t.Errorf("subject id = %q", resolved.Subject.ID) + } + assertClaims(t, "resolved Subject.Claims", resolved.Subject.Claims) + // The request headers carry the inbound token, so the result does + // not echo them. The other input fields come back. + if len(resolved.Headers) != 0 { + t.Errorf("resolved payload echoes request headers: %v", resolved.Headers) + } + if resolved.Source != TokenSourceBearer { + t.Errorf("source = %q", resolved.Source) + } + }) + + t.Run("InvokeResolved extensions", func(t *testing.T) { + payload := MessagePayload{Message: NewMessage("assistant", NewToolCallPart(ToolCall{ + ToolCallID: "acc-claims", Name: "get_compensation", + Arguments: map[string]any{"employee_id": "e-1"}, + }))} + res, ct, bg, err := eng.InvokeResolved(NewIdentityPayload(TokenSourceBearer, headers), + "cmf.tool_pre_invoke", PayloadCMFMessage, payload, &Extensions{Meta: meta}, nil) + if err != nil { + t.Fatalf("InvokeResolved: %v", err) + } + defer bg.Close() + defer ct.Close() + wantAllowed(t, "tool call", res) + ext, err := res.DeserializeExtensions() + if err != nil { + t.Fatalf("decode extensions: %v", err) + } + if ext == nil || ext.Security == nil || ext.Security.Subject == nil { + t.Fatalf("no subject in result extensions: %+v", ext) + } + assertClaims(t, "result Security.Subject.Claims", ext.Security.Subject.Claims) + }) +} + +// roundTripFFI sends payload and ext through an engine with no plugins +// and returns what came back. +func roundTripFFI(t *testing.T, payload MessagePayload, ext *Extensions) (*MessagePayload, *Extensions) { + t.Helper() + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault: %v", err) + } + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + res, ct, bg, err := eng.InvokeByName("cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, nil) + if err != nil { + t.Fatalf("InvokeByName: %v", err) + } + defer bg.Close() + defer ct.Close() + gotPayload, err := DeserializePayload[MessagePayload](res) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + gotExt, err := res.DeserializeExtensions() + if err != nil { + t.Fatalf("decode extensions: %v", err) + } + return gotPayload, gotExt +} + +// TestFFIRoundTripResourceBlob checks a resource blob crosses the FFI +// both ways. Rust encodes bytes as an array of integers, not bin. +func TestFFIRoundTripResourceBlob(t *testing.T) { + blob := []byte{0x00, 0x7f, 0x80, 0xff} + got, _ := roundTripFFI(t, MessagePayload{Message: NewMessage("user", NewResourcePart(Resource{ + ResourceRequestID: "rr-1", URI: "file:///a.bin", ResourceType: "blob", Blob: blob, + }))}, nil) + if got == nil || len(got.Message.Content) != 1 || got.Message.Content[0].ResourceContent == nil { + t.Fatalf("resource lost: %+v", got) + } + if b := got.Message.Content[0].ResourceContent.Blob; string(b) != string(blob) { + t.Errorf("blob = %v, want %v", b, blob) + } +} + +// TestFFIRoundTripEmptyMessage checks a message with no content parts +// is accepted. +func TestFFIRoundTripEmptyMessage(t *testing.T) { + got, _ := roundTripFFI(t, MessagePayload{Message: NewMessage("user")}, nil) + if got == nil || got.Message.Role != "user" || len(got.Message.Content) != 0 { + t.Errorf("unexpected payload: %+v", got) + } +} + +// TestFFIRoundTripExtensionSlots sends every slot the Go mirror added +// and checks it comes back, including the nil versus empty distinction +// on Option fields. +func TestFFIRoundTripExtensionSlots(t *testing.T) { + ttl := uint64(60) + ext := &Extensions{ + Security: &SecurityExtension{ + Subject: &SubjectExtension{ID: "alice", Claims: nonStringClaims()}, + Client: &ClientExtension{ + ClientID: "web", ClientName: "Web", TrustLevel: ClientTrustThirdParty, + AuthorizedScopes: []string{"read"}, Claims: map[string]any{"tier": int64(2)}, + }, + CallerWorkload: &WorkloadIdentity{ + SpiffeID: "spiffe://corp.com/caller", TrustDomain: "corp.com", + AttestedAt: "2026-01-02T03:04:05Z", Selectors: []string{"k8s:ns:a"}, + }, + ThisWorkload: &WorkloadIdentity{SpiffeID: "spiffe://corp.com/gw", ClientID: "gw"}, + Data: map[string]DataPolicy{ + "none": {AllowedActions: []string{}}, + "any": {}, + "read": {AllowedActions: []string{"read"}}, + }, + }, + Http: &HttpExtension{ + RequestHeaders: map[string]string{"x-a": "1"}, + Method: "POST", Path: "/v1/items", Host: "api.corp.com", Scheme: "https", Status: 201, + }, + Delegation: &DelegationExtension{Chain: []DelegationHop{{ + SubjectID: "alice", Audience: "api", TTLSeconds: &ttl, Strategy: "token_exchange", + Timestamp: "2026-01-02T03:04:05Z", + AuthorizationDetails: []map[string]any{{"type": "payment", "actions": []any{"pay"}, "amount": "10"}}, + }}, Depth: 1}, + CandidateConstraint: &CandidateConstraintExtension{ + AllowModels: []string{}, DenyModels: []string{"m-old"}, OnEmpty: OnEmptyFallback, + }, + MCP: &MCPExtension{ + Resource: &ResourceMetadata{URI: "hr://x", Annotations: map[string]any{"audience": []any{"user"}}}, + Prompt: &PromptMetadata{ + Name: "p", + Arguments: []any{map[string]any{"name": "q", "required": true}}, + Annotations: map[string]any{"priority": 0.5}, + }, + }, + } + _, got := roundTripFFI(t, MessagePayload{Message: NewMessage("user", NewTextPart("hi"))}, ext) + if got == nil || got.Security == nil { + t.Fatalf("security lost: %+v", got) + } + sec := got.Security + assertClaims(t, "subject claims", sec.Subject.Claims) + if sec.Client == nil || sec.Client.ClientID != "web" || sec.Client.TrustLevel != ClientTrustThirdParty || + !reflect.DeepEqual(sec.Client.AuthorizedScopes, []string{"read"}) { + t.Errorf("client = %+v", sec.Client) + } + if v, ok := asFloat(sec.Client.Claims["tier"]); !ok || v != 2 { + t.Errorf("client claims = %#v", sec.Client.Claims) + } + if sec.CallerWorkload == nil || sec.CallerWorkload.SpiffeID != "spiffe://corp.com/caller" || + sec.CallerWorkload.AttestedAt == "" || len(sec.CallerWorkload.Selectors) != 1 { + t.Errorf("caller workload = %+v", sec.CallerWorkload) + } + if sec.ThisWorkload == nil || sec.ThisWorkload.ClientID != "gw" { + t.Errorf("this workload = %+v", sec.ThisWorkload) + } + if a := sec.Data["none"].AllowedActions; a == nil || len(a) != 0 { + t.Errorf("empty allowed_actions must stay empty, got %#v", a) + } + if a := sec.Data["any"].AllowedActions; a != nil { + t.Errorf("unset allowed_actions must stay nil, got %#v", a) + } + if a := sec.Data["read"].AllowedActions; !reflect.DeepEqual(a, []string{"read"}) { + t.Errorf("allowed_actions = %#v", a) + } + + if h := got.Http; h == nil || h.Method != "POST" || h.Path != "/v1/items" || h.Host != "api.corp.com" || + h.Scheme != "https" || h.Status != 201 { + t.Errorf("http = %+v", got.Http) + } + + if d := got.Delegation; d == nil || len(d.Chain) != 1 || d.Chain[0].Strategy != "token_exchange" || + len(d.Chain[0].AuthorizationDetails) != 1 || d.Chain[0].AuthorizationDetails[0]["amount"] != "10" { + t.Errorf("delegation = %+v", got.Delegation) + } + + cc := got.CandidateConstraint + if cc == nil || cc.AllowModels == nil || len(cc.AllowModels) != 0 || cc.AllowRegions != nil || + !reflect.DeepEqual(cc.DenyModels, []string{"m-old"}) || cc.OnEmpty != OnEmptyFallback { + t.Errorf("candidate constraint = %+v", cc) + } + + if m := got.MCP; m == nil || m.Resource == nil || m.Resource.Annotations["audience"] == nil || + m.Prompt == nil || len(m.Prompt.Arguments) != 1 || m.Prompt.Annotations["priority"] != 0.5 { + t.Errorf("mcp = %+v", got.MCP) + } +} diff --git a/go/cpex/ffi.go b/go/cpex/ffi.go index 28fad7c8..9d9b5011 100644 --- a/go/cpex/ffi.go +++ b/go/cpex/ffi.go @@ -6,42 +6,68 @@ // CGO declarations for the CPEX FFI layer. // // Declares the C function signatures from libcpex_ffi. These are -// opaque handles — Go callers use the PluginManager wrapper in -// manager.go rather than calling these directly. +// opaque handles. Go callers use the PolicyEngine wrapper in +// engine.go rather than calling these directly. package cpex /* #cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi #cgo darwin LDFLAGS: -framework CoreFoundation -framework Security +// The Rust staticlib needs libm, libdl and libpthread on Linux (rustc's +// native-static-libs). glibc 2.34+ folds dl and pthread into libc, but +// older glibc and libm still need them named; musl ships stubs. +#cgo linux LDFLAGS: -lm -ldl -lpthread #include #include // Opaque handles -typedef void* CpexManager; +typedef void* CpexEngine; typedef void* CpexContextTable; typedef void* CpexBackgroundTasks; +// ABI version +uint32_t cpex_ffi_abi_version(void); + // Runtime configuration int cpex_configure_runtime(int worker_threads); -// Manager lifecycle -CpexManager cpex_manager_new(const char* config_yaml, int config_len); -CpexManager cpex_manager_new_default(); -int cpex_load_config(CpexManager mgr, const char* config_yaml, int config_len); -int cpex_initialize(CpexManager mgr); -void cpex_shutdown(CpexManager mgr); +// Engine lifecycle +CpexEngine cpex_engine_new(const char* config_yaml, int config_len); +CpexEngine cpex_engine_new_default(); +int cpex_load_config(CpexEngine engine, const char* config_yaml, int config_len); +int cpex_initialize(CpexEngine engine); +void cpex_shutdown(CpexEngine engine); // Query -int cpex_has_hooks_for(CpexManager mgr, const char* hook_name, int hook_len); -int cpex_plugin_count(CpexManager mgr); -int cpex_is_initialized(CpexManager mgr); -int cpex_plugin_names(CpexManager mgr, uint8_t** names_msgpack_out, int* names_len_out); +int cpex_has_hooks_for(CpexEngine engine, const char* hook_name, int hook_len); +int cpex_plugin_count(CpexEngine engine); +int cpex_is_initialized(CpexEngine engine); +int cpex_plugin_names(CpexEngine engine, uint8_t** names_msgpack_out, int* names_len_out); + +// Builtins, transport and secret providers +int cpex_apl_install(CpexEngine engine, uint32_t flags); + +// Secrets +int cpex_refresh_secrets(CpexEngine engine, uint8_t** report_msgpack_out, int* report_len_out); +int cpex_secrets_last_success(CpexEngine engine, const char* provider, int provider_len, int64_t* unix_ms_out); // Invoke int cpex_invoke( - CpexManager mgr, + CpexEngine engine, + const char* hook_name, int hook_len, + uint8_t payload_type, + const uint8_t* payload_msgpack, int payload_len, + const uint8_t* extensions_msgpack, int extensions_len, + CpexContextTable context_table, + uint8_t** result_msgpack_out, int* result_len_out, + CpexContextTable* context_table_out, + CpexBackgroundTasks* bg_handle_out +); +int cpex_invoke_resolved( + CpexEngine engine, + const uint8_t* identity_msgpack, int identity_len, const char* hook_name, int hook_len, uint8_t payload_type, const uint8_t* payload_msgpack, int payload_len, @@ -54,7 +80,7 @@ int cpex_invoke( // Background tasks int cpex_wait_background( - CpexManager mgr, + CpexEngine engine, CpexBackgroundTasks bg_handle, uint8_t** errors_msgpack_out, int* errors_len_out ); diff --git a/go/cpex/identity.go b/go/cpex/identity.go index 1578ab29..9e76e26e 100644 --- a/go/cpex/identity.go +++ b/go/cpex/identity.go @@ -3,22 +3,22 @@ // SPDX-License-Identifier: Apache-2.0 // // IdentityPayload — Go view of the identity.resolve hook's input/output -// state (crates/cpex-core/src/identity/payload.rs). +// state (praxis_policy_core::identity::IdentityPayload). // // Hosts that don't run in-process Rust (i.e. the Go FFI bindings) drive // identity resolution like this: // // idp := cpex.NewIdentityPayload(cpex.TokenSourceBearer, headers) -// res, ct, bg, err := mgr.InvokeByName( +// res, ct, bg, err := engine.InvokeByName( // cpex.HookIdentityResolve, cpex.PayloadIdentity, idp, ext, nil) // // On success res.ModifiedPayload holds the resolved IdentityPayload. // resolved, _ := cpex.DeserializePayload[cpex.IdentityPayload](res) // // resolved.Subject carries roles / permissions / teams. // -// The resolved Subject / Client / RawCredentials are then applied onto -// the Extensions passed to the downstream tool/prompt/resource hook so -// per-route APL gates (require(role.*), redact(!perm.*), Cedar) and the -// OAuth delegator can see the principal and its inbound credentials. +// A Go host can copy the resolved Subject and Client into downstream +// Extensions.Security. Raw token bytes do not survive Go/FFI serialization. +// InvokeResolved keeps resolver-produced raw credentials inside Rust between +// identity.resolve and the downstream hook when both run in one call. package cpex @@ -26,7 +26,7 @@ import "github.com/vmihailenco/msgpack/v5" // HookIdentityResolve is the hook name the identity resolver chain is // registered under. Matches HOOK_IDENTITY_RESOLVE in -// crates/cpex-core/src/identity/hook.rs. +// praxis_policy_core::identity. const HookIdentityResolve = "identity.resolve" // TokenSource values identify where a credential comes from. Wire form @@ -43,7 +43,9 @@ const ( // IdentityPayload mirrors the Rust IdentityPayload. Input fields // (Source, SourceHeader, Headers, ClientHost, ClientPort) are set by the // host before the call; output fields (Subject, Client, …) are populated -// by the resolver chain and read back from the result. +// by the resolver chain and read back from the result. The result leaves +// Headers empty: they carry the inbound credential, so the engine does +// not echo them back. // // raw_token is intentionally absent: it is #[serde(skip)] on the Rust // side (zeroized, never serialized). Tokens travel in Headers — each @@ -51,9 +53,8 @@ const ( // from there. // // Output slots the Go side doesn't model field-by-field are carried as -// msgpack.RawMessage so they round-trip verbatim — a host can forward -// them onto the next hook's Extensions without the bindings needing a -// typed mirror of every Rust extension. +// msgpack.RawMessage for inspection. RawCredentials cannot reconstruct +// token bytes that Rust deliberately excludes from serialization. type IdentityPayload struct { Source string `msgpack:"source"` SourceHeader string `msgpack:"source_header,omitempty"` diff --git a/go/cpex/legacy_hooks_test.go b/go/cpex/legacy_hooks_test.go new file mode 100644 index 00000000..5735027e --- /dev/null +++ b/go/cpex/legacy_hooks_test.go @@ -0,0 +1,103 @@ +// Location: ./go/cpex/legacy_hooks_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Hook names removed in 0.3 are refused at invoke time. Such a name reaches +// no handler, so dispatching it would come back allowed with no policy +// evaluated. Run against the real Rust runtime via cgo; build the staticlib +// first: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "testing" +) + +// removedHooks pairs each removed name with its replacement, matching +// cpex::host::REMOVED_HOOKS in the Rust facade. +var removedHooks = map[string]string{ + "tool_pre_invoke": "cmf.tool_pre_invoke", + "tool_post_invoke": "cmf.tool_post_invoke", + "prompt_pre_fetch": "cmf.prompt_pre_invoke", + "cmf.prompt_pre_fetch": "cmf.prompt_pre_invoke", + "prompt_post_fetch": "cmf.prompt_post_invoke", + "cmf.prompt_post_fetch": "cmf.prompt_post_invoke", + "resource_pre_fetch": "cmf.resource_pre_fetch", + "resource_post_fetch": "cmf.resource_post_fetch", + "identity_resolve": "identity.resolve", + "token_delegate": "token.delegate", + "cmf.http_request": "http.request", + "cmf.http_response": "http.response", +} + +func newLegacyHookEngine(t *testing.T) *PolicyEngine { + t.Helper() + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + if err := eng.EnableAPL(); err != nil { + t.Fatalf("EnableAPL failed: %v", err) + } + if err := eng.LoadConfig("engine_settings:\n dispatch: hooks\nplugins: []\n"); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + return eng +} + +// TestInvokeByNameRejectsRemovedHooks: every removed name fails with +// ErrCpexInvalidInput instead of an allowed result. +func TestInvokeByNameRejectsRemovedHooks(t *testing.T) { + eng := newLegacyHookEngine(t) + defer eng.Shutdown() + + for removed := range removedHooks { + res, _, _, err := eng.InvokeByName(removed, PayloadGeneric, map[string]any{}, nil, nil) + if !errors.Is(err, ErrCpexInvalidInput) { + t.Errorf("InvokeByName(%q): want ErrCpexInvalidInput, got result %+v, err %v", removed, res, err) + } + } +} + +// TestInvokeResolvedRejectsRemovedHooks: the fused identity path refuses +// the same names. +func TestInvokeResolvedRejectsRemovedHooks(t *testing.T) { + eng := newLegacyHookEngine(t) + defer eng.Shutdown() + + identity := NewIdentityPayload(TokenSourceBearer, map[string]string{"authorization": "Bearer x"}) + for removed := range removedHooks { + res, _, _, err := eng.InvokeResolved(identity, removed, PayloadGeneric, map[string]any{}, nil, nil) + if !errors.Is(err, ErrCpexInvalidInput) { + t.Errorf("InvokeResolved(%q): want ErrCpexInvalidInput, got result %+v, err %v", removed, res, err) + } + } +} + +// TestInvokeByNameAcceptsReplacementHooks: the replacement names still +// dispatch; with nothing registered they allow. +func TestInvokeByNameAcceptsReplacementHooks(t *testing.T) { + eng := newLegacyHookEngine(t) + defer eng.Shutdown() + + for _, replacement := range removedHooks { + res, ct, bg, err := eng.InvokeByName(replacement, PayloadGeneric, map[string]any{}, nil, nil) + if err != nil { + t.Errorf("InvokeByName(%q): unexpected error %v", replacement, err) + continue + } + if !res.ContinueProcessing { + t.Errorf("InvokeByName(%q): want allow, got %+v", replacement, res) + } + ct.Close() + bg.Close() + } +} diff --git a/go/cpex/secrets.go b/go/cpex/secrets.go new file mode 100644 index 00000000..695c53da --- /dev/null +++ b/go/cpex/secrets.go @@ -0,0 +1,121 @@ +// Location: ./go/cpex/secrets.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Secret refresh and health. +// +// The engine resolves every declared secret once, at Initialize, and +// never re-reads on its own. The host decides when: call RefreshSecrets +// on a schedule to re-read values (renewing a Vault token when due), and +// SecretsLastSuccess to alarm on a stale credential. + +package cpex + +import ( + "fmt" + "time" + "unsafe" + + "github.com/vmihailenco/msgpack/v5" +) + +/* +#include +#include + +// Duplicated from the ffi.go preamble; see the note in engine.go about +// cgo not merging declarations across files. +typedef void* CpexEngine; + +extern int cpex_refresh_secrets(CpexEngine engine, uint8_t** report_msgpack_out, int* report_len_out); +extern int cpex_secrets_last_success(CpexEngine engine, const char* provider, int provider_len, int64_t* unix_ms_out); +extern void cpex_free_bytes(uint8_t* ptr, int len); +*/ +import "C" + +// RefreshReport is the outcome of one RefreshSecrets call. +type RefreshReport struct { + // Updated names the values whose bytes changed. + Updated []string `msgpack:"updated"` + // Unchanged counts the values that were re-read and found unchanged. + Unchanged uint64 `msgpack:"unchanged"` + // Failed lists the values that could not be re-read. Each keeps its + // last-good value. + Failed []RefreshFailure `msgpack:"failed"` +} + +// RefreshFailure is one value that failed to re-read. +type RefreshFailure struct { + // Secret is the declared value name. + Secret string `msgpack:"secret"` + // Provider is the provider the value is bound to. + Provider string `msgpack:"provider"` + // Kind is the failure class: "config", "reference", "not_found", + // "malformed", "backend", or "other". + Kind string `msgpack:"kind"` + // Message is what the provider reported. It never contains the secret. + Message string `msgpack:"message"` +} + +// RefreshSecrets re-reads every declared secret and reports what changed +// and what failed. A value that fails to re-read is listed in Failed and +// is not an error return. An engine with no `secrets:` block, or one not +// yet initialized, returns an empty report. +// +// On failure the returned error wraps a typed sentinel +// (ErrCpexInvalidHandle, ErrCpexSerialize, ErrCpexTimeout, ErrCpexPanic). +func (m *PolicyEngine) RefreshSecrets() (*RefreshReport, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, fmt.Errorf("RefreshSecrets: %w", ErrCpexInvalidHandle) + } + + var reportPtr *C.uint8_t + var reportLen C.int + rc := C.cpex_refresh_secrets(m.handle, &reportPtr, &reportLen) + if rc != 0 { + return nil, errorFromRC(int(rc), "RefreshSecrets") + } + + bytes := C.GoBytes(unsafe.Pointer(reportPtr), reportLen) + C.cpex_free_bytes(reportPtr, reportLen) + + var report RefreshReport + if err := msgpack.Unmarshal(bytes, &report); err != nil { + return nil, fmt.Errorf("RefreshSecrets: decode failed: %w", err) + } + return &report, nil +} + +// SecretsLastSuccess reports when provider last re-read all of its +// values without a failure. Initialization counts as the first success. +// +// ok is false, and the time is zero, when there is no such moment: the +// provider has never refreshed successfully, the name is not a declared +// provider, or secrets are not resolved yet. Failed refreshes retain the +// last successful time; inspect RefreshReport.Failed to detect an outage. +// +// On failure the returned error wraps a typed sentinel +// (ErrCpexInvalidHandle, ErrCpexInvalidInput). +func (m *PolicyEngine) SecretsLastSuccess(provider string) (at time.Time, ok bool, err error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return time.Time{}, false, fmt.Errorf("SecretsLastSuccess: %w", ErrCpexInvalidHandle) + } + + cName := C.CString(provider) + defer C.free(unsafe.Pointer(cName)) + + var unixMs C.int64_t + rc := C.cpex_secrets_last_success(m.handle, cName, C.int(len(provider)), &unixMs) + if rc != 0 { + return time.Time{}, false, errorFromRC(int(rc), "SecretsLastSuccess") + } + if unixMs == 0 { + return time.Time{}, false, nil + } + return time.UnixMilli(int64(unixMs)), true, nil +} diff --git a/go/cpex/secrets_test.go b/go/cpex/secrets_test.go new file mode 100644 index 00000000..e70f0bc2 --- /dev/null +++ b/go/cpex/secrets_test.go @@ -0,0 +1,144 @@ +// Location: ./go/cpex/secrets_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo + +package cpex + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// An engine with no secrets block returns an empty report. +func TestRefreshSecretsWithNoSecretsIsEmpty(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + defer eng.Shutdown() + if err := eng.EnableAPL(); err != nil { + t.Fatalf("EnableAPL failed: %v", err) + } + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + report, err := eng.RefreshSecrets() + if err != nil { + t.Fatalf("RefreshSecrets failed: %v", err) + } + if len(report.Updated) != 0 || report.Unchanged != 0 || len(report.Failed) != 0 { + t.Errorf("expected an empty report, got %+v", report) + } +} + +// With no declared provider there is no last success. +func TestSecretsLastSuccessUnknownProvider(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + defer eng.Shutdown() + if err := eng.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + at, ok, err := eng.SecretsLastSuccess("vault") + if err != nil { + t.Fatalf("SecretsLastSuccess failed: %v", err) + } + if ok || !at.IsZero() { + t.Errorf("expected no last success, got ok=%v at=%v", ok, at) + } +} + +func TestSecretsAfterShutdownReturnTypedError(t *testing.T) { + eng, err := NewPolicyEngineDefault() + if err != nil { + t.Fatalf("NewPolicyEngineDefault failed: %v", err) + } + eng.Shutdown() + + if _, err := eng.RefreshSecrets(); !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("RefreshSecrets: expected ErrCpexInvalidHandle, got %v", err) + } + if _, _, err := eng.SecretsLastSuccess("env"); !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("SecretsLastSuccess: expected ErrCpexInvalidHandle, got %v", err) + } +} + +// A `kind: file` provider reports a rotation as an update and a missing +// file as a failure, and its last success tracks the latest refresh. +func TestRefreshSecretsReportsUpdatesAndFailures(t *testing.T) { + secretFile := filepath.Join(t.TempDir(), "api_key") + if err := os.WriteFile(secretFile, []byte("first"), 0o600); err != nil { + t.Fatalf("write secret: %v", err) + } + yaml := "engine_settings:\n dispatch: hooks\n" + + "secrets:\n" + + " providers:\n local:\n kind: file\n" + + " values:\n api_key:\n provider: local\n ref: " + secretFile + "\n" + eng := accEngine(t, yaml) + + first, ok, err := eng.SecretsLastSuccess("local") + if err != nil { + t.Fatalf("SecretsLastSuccess failed: %v", err) + } + if !ok || first.IsZero() { + t.Fatalf("expected a last success after Initialize, got ok=%v at=%v", ok, first) + } + + report, err := eng.RefreshSecrets() + if err != nil { + t.Fatalf("RefreshSecrets failed: %v", err) + } + if len(report.Updated) != 0 || report.Unchanged != 1 || len(report.Failed) != 0 { + t.Errorf("expected one unchanged secret, got %+v", report) + } + + if err := os.WriteFile(secretFile, []byte("second"), 0o600); err != nil { + t.Fatalf("rewrite secret: %v", err) + } + report, err = eng.RefreshSecrets() + if err != nil { + t.Fatalf("RefreshSecrets failed: %v", err) + } + if len(report.Updated) != 1 || report.Updated[0] != "api_key" || report.Unchanged != 0 || len(report.Failed) != 0 { + t.Errorf("expected api_key updated, got %+v", report) + } + again, ok, err := eng.SecretsLastSuccess("local") + if err != nil { + t.Fatalf("SecretsLastSuccess failed: %v", err) + } + if !ok || again.Before(first) { + t.Errorf("expected last success at or after %v, got ok=%v at=%v", first, ok, again) + } + + if err := os.Remove(secretFile); err != nil { + t.Fatalf("remove secret: %v", err) + } + report, err = eng.RefreshSecrets() + if err != nil { + t.Fatalf("RefreshSecrets failed: %v", err) + } + if len(report.Updated) != 0 || report.Unchanged != 0 || len(report.Failed) != 1 { + t.Fatalf("expected one failure, got %+v", report) + } + failure := report.Failed[0] + if failure.Secret != "api_key" || failure.Provider != "local" || failure.Kind != "not_found" { + t.Errorf("unexpected failure fields: %+v", failure) + } + if failure.Message == "" { + t.Error("expected a failure message") + } + if strings.Contains(failure.Message, "second") { + t.Error("the failure message must not carry the secret") + } + if at, ok, err := eng.SecretsLastSuccess("local"); err != nil || !ok || !at.Equal(again) { + t.Errorf("expected a failed refresh to retain last success %v, got ok=%v at=%v err=%v", again, ok, at, err) + } +} diff --git a/go/cpex/types.go b/go/cpex/types.go index 1db1f663..531be8ee 100644 --- a/go/cpex/types.go +++ b/go/cpex/types.go @@ -7,7 +7,7 @@ // // All types use msgpack struct tags matching the Rust field names // for zero-copy serialization across the FFI boundary. Extension -// types mirror crates/cpex-core/src/extensions/. +// types mirror praxis_policy_core::extensions. package cpex @@ -15,6 +15,10 @@ import "github.com/vmihailenco/msgpack/v5" // Extensions carries capability-gated data alongside the payload. // Serialized to/from MessagePack when crossing the FFI boundary. +// +// The Rust raw_credentials slot has no Go field. Its tokens are never +// serialized, so a copy that crossed the FFI would carry empty tokens; +// use InvokeResolved to keep inbound credentials in Rust memory. type Extensions struct { Meta *MetaExtension `msgpack:"meta,omitempty"` Security *SecurityExtension `msgpack:"security,omitempty"` @@ -22,12 +26,16 @@ type Extensions struct { Delegation *DelegationExtension `msgpack:"delegation,omitempty"` Agent *AgentExtension `msgpack:"agent,omitempty"` Request *RequestExtension `msgpack:"request,omitempty"` - MCP *MCPExtension `msgpack:"mcp,omitempty"` - Completion *CompletionExtension `msgpack:"completion,omitempty"` - Provenance *ProvenanceExtension `msgpack:"provenance,omitempty"` - LLM *LLMExtension `msgpack:"llm,omitempty"` - Framework *FrameworkExtension `msgpack:"framework,omitempty"` - Custom map[string]any `msgpack:"custom,omitempty"` + // CandidateConstraint narrows the model or backend candidate set. + // Plugins holding write_candidate_constraint fill it; a host reads it + // back from the result to filter its routing choices. + CandidateConstraint *CandidateConstraintExtension `msgpack:"candidate_constraint,omitempty"` + MCP *MCPExtension `msgpack:"mcp,omitempty"` + Completion *CompletionExtension `msgpack:"completion,omitempty"` + Provenance *ProvenanceExtension `msgpack:"provenance,omitempty"` + LLM *LLMExtension `msgpack:"llm,omitempty"` + Framework *FrameworkExtension `msgpack:"framework,omitempty"` + Custom map[string]any `msgpack:"custom,omitempty"` } // MetaExtension carries entity identification for route resolution. @@ -40,11 +48,18 @@ type MetaExtension struct { } // SecurityExtension carries identity, labels, and data policies. +// +// It holds four identity slots: Subject (the user), Client (the OAuth +// client that brokered the request), CallerWorkload (the attested peer +// that called this service) and ThisWorkload (this service's own +// workload identity). type SecurityExtension struct { Labels []string `msgpack:"labels,omitempty"` Classification string `msgpack:"classification,omitempty"` Subject *SubjectExtension `msgpack:"subject,omitempty"` - Agent *AgentIdentity `msgpack:"agent,omitempty"` + Client *ClientExtension `msgpack:"client,omitempty"` + CallerWorkload *WorkloadIdentity `msgpack:"caller_workload,omitempty"` + ThisWorkload *WorkloadIdentity `msgpack:"this_workload,omitempty"` AuthMethod string `msgpack:"auth_method,omitempty"` Objects map[string]ObjectSecurityProfile `msgpack:"objects,omitempty"` Data map[string]DataPolicy `msgpack:"data,omitempty"` @@ -52,19 +67,50 @@ type SecurityExtension struct { // 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"` - Claims map[string]string `msgpack:"claims,omitempty"` -} - -// AgentIdentity represents this agent's own workload identity. -type AgentIdentity struct { - ClientID string `msgpack:"client_id,omitempty"` - WorkloadID string `msgpack:"workload_id,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"` +} + +// Client trust levels. A resolver can also set an operator-defined +// string, which travels verbatim. +const ( + ClientTrustFirstParty = "first_party" + ClientTrustThirdParty = "third_party" + ClientTrustInternal = "internal" +) + +// 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"` + // 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"` + AuthorizedScopes []string `msgpack:"authorized_scopes,omitempty"` + AuthorizedAudiences []string `msgpack:"authorized_audiences,omitempty"` + Roles []string `msgpack:"roles,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + Teams []string `msgpack:"teams,omitempty"` + Claims map[string]any `msgpack:"claims,omitempty"` +} + +// 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"` + // AttestedAt is an RFC 3339 timestamp. + AttestedAt string `msgpack:"attested_at,omitempty"` + Attestor string `msgpack:"attestor,omitempty"` + Selectors []string `msgpack:"selectors,omitempty"` + ClientID string `msgpack:"client_id,omitempty"` } // ObjectSecurityProfile is a security profile for a managed object. @@ -77,8 +123,11 @@ type ObjectSecurityProfile struct { // DataPolicy defines data handling policies. type DataPolicy struct { - ApplyLabels []string `msgpack:"apply_labels,omitempty"` - AllowedActions []string `msgpack:"allowed_actions,omitempty"` + ApplyLabels []string `msgpack:"apply_labels,omitempty"` + // AllowedActions distinguishes nil from empty: nil allows every + // action, an empty non-nil slice allows none. The tag has no + // omitempty so an empty slice is not dropped on the way to Rust. + AllowedActions []string `msgpack:"allowed_actions"` DeniedActions []string `msgpack:"denied_actions,omitempty"` Retention *RetentionPolicy `msgpack:"retention,omitempty"` } @@ -90,10 +139,19 @@ type RetentionPolicy struct { DeleteAfter string `msgpack:"delete_after,omitempty"` } -// HttpExtension carries HTTP request and response headers. +// HttpExtension carries the HTTP request line, headers and response +// status. Method and Path select `http:` routes; set them at the +// identity.resolve invocation so the route's own authentication list +// applies. type HttpExtension struct { 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. + Status uint16 `msgpack:"status,omitempty"` + Method string `msgpack:"method,omitempty"` + Path string `msgpack:"path,omitempty"` + Host string `msgpack:"host,omitempty"` + Scheme string `msgpack:"scheme,omitempty"` } // DelegationExtension carries the token delegation chain. @@ -108,14 +166,19 @@ type DelegationExtension struct { // DelegationHop is a single step in the delegation chain. type DelegationHop struct { - SubjectID string `msgpack:"subject_id,omitempty"` + // SubjectID is required on the Rust side, so it is always sent. + SubjectID string `msgpack:"subject_id"` SubjectType string `msgpack:"subject_type,omitempty"` Audience string `msgpack:"audience,omitempty"` ScopesGranted []string `msgpack:"scopes_granted,omitempty"` - Timestamp string `msgpack:"timestamp,omitempty"` - TTLSeconds *uint64 `msgpack:"ttl_seconds,omitempty"` - Strategy string `msgpack:"strategy,omitempty"` - FromCache bool `msgpack:"from_cache,omitempty"` + // AuthorizationDetails holds RFC 9396 authorization_details entries. + // Each is a JSON-shaped map with a "type" key plus any other fields. + AuthorizationDetails []map[string]any `msgpack:"authorization_details,omitempty"` + // Timestamp is an RFC 3339 timestamp. + Timestamp string `msgpack:"timestamp,omitempty"` + TTLSeconds *uint64 `msgpack:"ttl_seconds,omitempty"` + Strategy string `msgpack:"strategy,omitempty"` + FromCache bool `msgpack:"from_cache,omitempty"` } // AgentExtension carries agent execution context. @@ -135,7 +198,7 @@ type AgentExtension struct { } // ConversationContext is per-conversation summary state, shared across -// turns. Mirrors `cpex_core::extensions::agent::ConversationContext`. +// 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). @@ -178,18 +241,22 @@ type ToolMetadata struct { // ResourceMetadata is MCP resource metadata. type ResourceMetadata struct { - URI string `msgpack:"uri"` - Name string `msgpack:"name,omitempty"` - Description string `msgpack:"description,omitempty"` - MimeType string `msgpack:"mime_type,omitempty"` - ServerID string `msgpack:"server_id,omitempty"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + Description string `msgpack:"description,omitempty"` + MimeType string `msgpack:"mime_type,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` } // PromptMetadata is MCP prompt metadata. type PromptMetadata struct { Name string `msgpack:"name"` Description string `msgpack:"description,omitempty"` - ServerID string `msgpack:"server_id,omitempty"` + // Arguments are the prompt's declared arguments, JSON-shaped. + Arguments []any `msgpack:"arguments,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` } // CompletionExtension carries LLM completion information. @@ -225,6 +292,28 @@ type LLMExtension struct { Capabilities []string `msgpack:"capabilities,omitempty"` } +// Candidate-constraint OnEmpty values. +const ( + OnEmptyDeny = "deny" + OnEmptyFallback = "fallback" +) + +// CandidateConstraintExtension narrows the set of models, regions or +// sites a router may pick. The Allow* fields distinguish nil from +// empty: nil leaves that dimension unconstrained, an empty non-nil +// slice allows nothing. Their tags have no omitempty so an empty slice +// reaches Rust as an empty list. +type CandidateConstraintExtension struct { + AllowModels []string `msgpack:"allow_models"` + DenyModels []string `msgpack:"deny_models,omitempty"` + AllowRegions []string `msgpack:"allow_regions"` + AllowSites []string `msgpack:"allow_sites"` + MaxCostTiers []string `msgpack:"max_cost_tiers,omitempty"` + Custom map[string]string `msgpack:"custom,omitempty"` + // OnEmpty is OnEmptyDeny or OnEmptyFallback. Empty means deny. + OnEmpty string `msgpack:"on_empty,omitempty"` +} + // FrameworkExtension carries agentic framework context. type FrameworkExtension struct { Framework string `msgpack:"framework,omitempty"` diff --git a/rustfmt.toml b/rustfmt.toml index e9794a8d..93257ed1 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -4,7 +4,7 @@ # Nightly-only options (group_imports, imports_granularity, wrap_comments, # format_code_in_doc_comments, normalize_comments, reorder_impl_items, …) are # intentionally omitted; they error or no-op on stable. -edition = "2021" +edition = "2024" max_width = 100 newline_style = "Unix" use_field_init_shorthand = true diff --git a/tests/parity/README.md b/tests/parity/README.md new file mode 100644 index 00000000..40e95cbb --- /dev/null +++ b/tests/parity/README.md @@ -0,0 +1,303 @@ +# Parity snapshots + +Golden decision snapshots recorded from the CPEX 0.2.3 engine, and a parity +check that replays the same requests through CPEX 0.3.0 (PPE 0.5.0) and +diffs the outcomes against them. Each scenario is a config plus an ordered +request set. A difference fails the check unless it is listed, with its +cause, in `intended-differences.yaml`. + +## Provenance + +- Engine: CPEX 0.2.3, built from this workspace at commit + `bf17a8a36031d63e58928a4faa44168c3ef3b94e` (branch + `refactor/adopt-ppe-core`, 2026-10-07), with the `cpex` facade's `full` + feature. +- Toolchain: rustc 1.96.0. +- Valkey: `valkey/valkey:8-alpine` in Docker. +- Capture harness: the sources committed in `c981609` (`test(parity): + capture 0.2.3 decision snapshots`) are retained in `baseline/`, including + their workspace lockfile. `capture-baseline.py` runs them out of tree + against the immutable `v0.2.3` commit + `e3a2b30bbaf14682808f4e1de4e3e5fa5be9f481`. Its engine and builtin + sources match the original snapshot engine commit. + +`snapshots/` is the baseline and is never rewritten by the current harness. + +## Layout + +``` +tests/parity/ + harness/ harness (workspace member cpex-parity-harness) + baseline/ retained 0.2.3 capture sources and lockfile + capture-baseline.py pinned, out-of-tree 0.2.3 capture runner + hook-renames.yaml reviewed legacy-to-PPE hook rename table + intended-differences.yaml + reviewed 0.2.3 to 0.3.0 differences, with causes + scenarios// + config.yaml config in 0.2.3 form, with run-time placeholders + config.ppe.yaml the same config migrated to the 0.3.0 form + requests.yaml mocks, test tokens, hook names, ordered requests + snapshots/.json 0.2.3 outcomes, one file per scenario + snapshots/valkey/.keyspace.json + raw taint:v1 keyspace left by a 0.2.3 Valkey run +``` + +## Scenarios + +| Scenario | Source | Covers | Requests | +|---|---|---|---| +| `identity-jwt` | tutorial m01, m02 | JWT via JWKS URL; expired, wrong audience, untrusted issuer, forged signature, unknown kid, malformed | 11 | +| `result-shaping` | tutorial m03 | `result:` redact and mask by role and permission | 4 | +| `pdp-cel` | tutorial m05 | CEL with and without a custom `on_deny` code | 7 | +| `pdp-cedar` | cedar-direct crate test | Cedar permit, default deny, named forbid, missing principal | 4 | +| `pdp-opa` | opa crate test | OPA boolean and object decisions | 6 | +| `delegation-oauth` | tutorial m06 | RFC 8693 token exchange against a mock endpoint, refused audience | 4 | +| `session-memory` | tutorial m07 | session taint with the in-memory store | 9 | +| `session-valkey` | tutorial m07, tainting docs | the same requests with the Valkey store, plus keyspace export | 9 | +| `elicitation-ciba` | tutorial m08 | CIBA approve, refuse, unanswered, resume by another subject | 9 | +| `go-demo-cmf` | go-demo `cmf_plugins.yaml` | host plugins: label gate and response header injection | 5 | + +Every scenario that needs an identity uses the mock IdP described below. +Tutorial configs that point at Keycloak are rewritten to it; the policy logic +is unchanged. + +### Excluded + +- `pii-scanner` and `audit-logger` flows. Both builtins are removed in the + next release, so their snapshots could only ever be recorded as intended + differences. Configs that use them (tutorial m04 and the capstones, + `examples/go-demo/plugins.yaml`, the Python binding fixtures + `pii_deny.yaml` and `pii_redact.yaml`) are left out. The rest of each + capstone is covered by the scenarios above. +- The Python plugin host (`isolated_venv`): it needs a Python toolchain and + is covered by its own e2e suites. + +## Running + +Start a throwaway Valkey on a free port (the harness runs `FLUSHDB` on the +database it is given): + +```sh +docker run -d --rm --name cpex-parity-valkey -p 127.0.0.1:6391:6379 valkey/valkey:8-alpine +export PARITY_VALKEY_URL=redis://127.0.0.1:6391/0 +``` + +Run the parity check, the Valkey read-back and the determinism test: + +```sh +cargo test -p cpex-parity-harness +``` + +Print every difference against the baseline, marking the unlisted ones and +rendering them as allow-list entries for review: + +```sh +cargo run -p cpex-parity-harness -- --diff +``` + +Write a 0.3.0 capture, for example to inspect one scenario: + +```sh +cargo run -p cpex-parity-harness -- --out /tmp/parity identity-jwt +``` + +Options: `--root DIR` (default `tests/parity`), `--out DIR` (default +`target/parity` in the workspace; `snapshots/` is refused), `--valkey-url +URL` (default `PARITY_VALKEY_URL`), and scenario names to run a subset. + +Without a Valkey URL, Valkey scenarios print `SKIPPED`, the parity test +checks every other scenario (allow-list entries for the skipped one are not +reported as stale), and the read-back test is skipped. All of them report +the skip on stderr. + +### Regenerating the 0.2.3 baseline + +Use the retained harness when a scenario changes or a baseline needs +investigation. The runner exports the pinned engine from local Git history +to a fresh temporary directory, adds `baseline/` as its capture harness, +and uses the original lockfile. It requires Python 3.12 or later and the +0.2.3 commit in local Git history (fetch the `v0.2.3` tag if absent). + +```sh +python3 tests/parity/capture-baseline.py --out /tmp/cpex-0.2.3-snapshots +diff -ru tests/parity/snapshots /tmp/cpex-0.2.3-snapshots +``` + +It reads this branch's legacy configs and requests without renaming hooks. +The runner refuses the committed snapshot directory as an output. Review +any generated diff before replacing a baseline, then rerun PPE parity and +review the allow-list. `--prepare-only --workdir /tmp/cpex-0.2.3` prepares +an inspectable engine tree without running Cargo; the work directory must +be empty. Remaining arguments select scenarios or supply `--valkey-url`. +Do not run concurrent captures against the same Valkey database. + +## Request format + +`requests.yaml` fields: + +- `config`, `ppe_config`: the 0.2.3 and 0.3.0 config files. Defaults are + `config.yaml` and `config.ppe.yaml`. +- `hooks`: hook names a `mediate` request dispatches. Defaults are + `identity: identity.resolve`, `pre: cmf.tool_pre_invoke`, + `post: cmf.tool_post_invoke`. The rename table is applied here and to + each `invoke` request's `hook` before replaying. +- `mocks`: routes the mock upstream serves at `{{MOCK_URL}}`. A route + matches on method, path and `form` fields; `max_hits` lets a later route + answer the same request differently. `builtin: jwks` serves the test keys. +- `tokens`: JWTs minted at run time from `claims`, signed with a test key. +- `requests`: run in order against one engine instance, so session labels + and pending approvals carry over. Each has a stable `id`. + - `kind: mediate` runs the tutorial host loop: the identity hook (only when + the caller has a credential), the pre hook with a CMF `tool_call`, the + `backend` value as the tool result, then the post hook. `caller` takes + `token`, `raw_token`, `credentials`, `session` (sent as the agent + session id) and `resume` (the id of an earlier request whose pending + elicitation id is echoed in `X-Policy-Elicitation-Id`). + - `kind: invoke` dispatches one `hook` with a CMF `MessagePayload` JSON + `payload` and seeded `extensions` (`meta`, `security`, `http`, `agent`). + +Config placeholders: `{{MOCK_URL}}`, `{{CLIENT_SECRET_FILE}}` and +`{{VALKEY_URL}}`. + +## Snapshot format + +Pretty JSON with sorted keys. Per request: + +- `decision`: `allow`, `deny` or `pending`; `code`: the violation code. + `not_run` with code `harness.no_pending_elicitation:` means a `resume` + pointed at a request that did not suspend. +- `result`: what the caller sees after the post phase (`mediate` only). +- `stages`: one entry per hook dispatched, with `hook`, `continue`, the + full `violation` (code, reason, plugin, protocol code, details), + `payload_modified`, the modified `payload` (CMF message or resolved + identity), the modified `extensions` (labels, subject, HTTP headers, + delegation, credential metadata) and non-fatal plugin `errors`. +- `upstream_calls`: mock upstream calls the request caused, in order, with + credential form fields dropped. `load.upstream_calls` holds calls made + while the engine initialized (the JWKS fetch). + +The keyspace export maps each `taint:v1:*` key to its type, sorted members +and whether a TTL is set. + +## Determinism and secrets + +- Set-valued fields (roles, permissions, labels, tags, scopes) are sorted. +- Wall-clock values (`expires_at`, `timestamp`, `resolved_at`, + `attested_at`, `age_seconds`) are replaced with `